Compare commits

..

33 Commits

Author SHA1 Message Date
Corentin Thomasset
05a8cadbf5 wip 2025-12-27 02:49:03 +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
Corentin Thomasset
f903c33d26 chore(release): update versions (#700)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-21 19:05:52 +01:00
Corentin Thomasset
4342b319ea fix(webhook): added prepublish script (#699) 2025-12-21 18:04:31 +00:00
Corentin Thomasset
815f6f94f8 fix(sdk): added prepublishing script (#698) 2025-12-21 18:01:02 +00:00
Corentin Thomasset
96f29ba58f chore(release): update versions (#676)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-21 18:51:43 +01:00
Corentin Thomasset
33e3de9b8f chore(changesets): marked docker updates minor for monthly upgrade (#697) 2025-12-21 17:50:09 +00:00
Corentin Thomasset
1c64bca297 fix(cli): prepublish script (#696) 2025-12-21 17:46:00 +00:00
Corentin Thomasset
f7bf202230 fix(tests): add createdAt field to user for deterministic ordering (#694) 2025-12-20 23:38:21 +00:00
Corentin Thomasset
5b905a1714 fix(demo): properly lazy load demo http client mock (#693) 2025-12-20 15:36:40 +01:00
Corentin Thomasset
7a4a3d4c5b feat(admin): user listing and search (#692) 2025-12-19 23:29:34 +01:00
Corentin Thomasset
d795798931 feat(app): admin pages (#689) 2025-12-16 23:50:25 +00:00
Corentin Thomasset
95662d025f refactor(auth): update better auth to 1.4 (#686) 2025-12-12 17:33:32 +01:00
Jibran Iqbal
9d9be949b0 feat(mobile): in-app document viewer (#667)
* Keep the phone logged in

* Added document sheet

* Make button outline

* Fix colors

* Design accroding to base design

* Design accroding to base design

* Added download and share

* Added view document screen

* Added view document screen

* Screen is launching

* fix toolbar issue

* Fix the button

* fix all the conflicts

* revert unncessary bloat

* remove packages

* pnpnm i

* Fixed some issues

* Added error state

* added header in loading and error state

* Removed duplication  in render

* added correct name of app

* Update apps/mobile/app.json

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>

---------

Co-authored-by: jibraniqbal666 <jibran.iqbal@protonmail.com>
Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-12-12 17:27:36 +01:00
Corentin Thomasset
cf91515cfe feat(search): implement asynchronous document indexing and synchronization (#685)
* feat(search): implement asynchronous document indexing and synchronization

* Update .changeset/chilly-queens-knock.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update .changeset/chilly-queens-knock.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-11 15:06:59 +00:00
Corentin Thomasset
d6f71ba5ec refactor(search): removed database fts triggers (#683) 2025-12-11 15:13:40 +01:00
Corentin Thomasset
5bdb7c06bf feat(documents): add document.deleted event (#684) 2025-12-11 01:30:58 +01:00
Corentin Thomasset
2872c979fa refactor(server): add restore document usecase with event (#681) 2025-12-07 23:24:27 +01:00
Corentin Thomasset
23e66aeadf fix(ci): run linters in quiet mode to reduce output noise (#680) 2025-12-06 22:29:37 +00:00
Corentin Thomasset
6f38659638 fix(client): properly load default fie icon (#679) 2025-12-06 22:40:01 +01:00
Corentin Thomasset
e3e0078673 feat(documents): use update usecase when content extraction (#678) 2025-12-06 21:51:09 +01:00
Corentin Thomasset
2cf86e5968 feat(documents): add document.updated internal event (#674) 2025-12-06 17:31:44 +01:00
Corentin Thomasset
76a72ace8d fix(i18n): correct capitalization in zh changeset (#677) 2025-12-06 14:03:14 +00:00
Corentin Thomasset
17d6e9aa6a feat(i18n): add Chinese (zh) translations (#673) (#675)
* feat(i18n): add Chinese (zh) translations (#673)

* feat(i18n): add Chinese (zh) translations

* chore(changeset): chinese language support

Updated the translation support to include simplified Chinese.

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>

* refactor(client): linted locales

---------

Co-authored-by: TMs <tms@imtms.com>
2025-12-06 13:55:37 +00:00
Corentin Thomasset
f488e63c38 feat(documents): added document.trashed internal event (#671) 2025-12-04 16:34:59 +01:00
Corentin Thomasset
0092e530b7 refactor(server): introduced document.created event (#670) 2025-12-03 23:57:55 +01:00
Corentin Thomasset
364b58b74d chore(server): updated hono related deps (#669) 2025-12-02 22:55:32 +01:00
Corentin Thomasset
d08cf2b195 chore(deps): updated linting dependencies (#668) 2025-12-02 19:57:32 +01:00
Corentin Thomasset
fcd440cbbb feat(server): refactor server initialization and DI management (#666) 2025-12-02 18:22:25 +01:00
Jibran Iqbal
d588e417c9 feat(mobile): add view document screen (#651)
* Keep the phone logged in

* Added document sheet

* Make button outline

* Fix colors

* Design accroding to base design

* Design accroding to base design

* Added download and share

* fix the login issue again

* fix the login issue again

* Fixed copilot suggestions

* Update document-action-sheet.tsx

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>

* Update documents-list.screen.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* change to cacheDirectory

* rewrite auth logic

* Ran pnpm lint:fix

* Update apps/mobile/src/modules/documents/documents.services.ts

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>

* fix all lint

* fix type issues

* fix lint issues

* fix types issues

* fix types issues

* fix lint issues

* fix type issues

* fix type issues

---------

Co-authored-by: jibraniqbal666 <jibran.iqbal@protonmail.com>
Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-02 18:19:07 +01:00
Corentin Thomasset
ca06919bb8 feat(server): introduced event driven pattern (#665) 2025-12-02 13:59:05 +01:00
170 changed files with 9119 additions and 1678 deletions

View File

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

View File

@@ -29,7 +29,7 @@ jobs:
run: pnpm -r --parallel -F "./packages/*" build run: pnpm -r --parallel -F "./packages/*" build
- name: Run linters - name: Run linters
run: pnpm -r --parallel lint run: pnpm -r --parallel lint --quiet
- name: Type check - name: Type check
# Exclude docs as their are some typing issues we are ok with for now # Exclude docs as their are some typing issues we are ok with for now

View File

@@ -1,7 +1,7 @@
{ {
"expo": { "expo": {
"name": "mobile", "name": "Papra",
"slug": "mobile", "slug": "papra",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./src/assets/images/icon.png", "icon": "./src/assets/images/icon.png",
@@ -9,7 +9,8 @@
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true,
"bundleIdentifier": "app.papra.ios"
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
@@ -19,7 +20,8 @@
"monochromeImage": "./src/assets/images/android-icon-monochrome.png" "monochromeImage": "./src/assets/images/android-icon-monochrome.png"
}, },
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false "predictiveBackGestureEnabled": false,
"package": "app.papra.android"
}, },
"web": { "web": {
"output": "static", "output": "static",

View File

@@ -11,6 +11,14 @@ export default function RootLayout() {
<Stack.Screen name="auth/login" options={{ headerShown: false }} /> <Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="auth/signup" options={{ headerShown: false }} /> <Stack.Screen name="auth/signup" options={{ headerShown: false }} />
<Stack.Screen name="(with-organizations)" options={{ headerShown: false }} /> <Stack.Screen name="(with-organizations)" options={{ headerShown: false }} />
<Stack.Screen
name="document/view"
options={{
headerShown: false,
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
</Stack> </Stack>
<StatusBar style="auto" /> <StatusBar style="auto" />
</ApiProvider> </ApiProvider>

View File

@@ -0,0 +1,3 @@
import DocumentViewScreen from '@/modules/documents-actions/screens/document-view.screen';
export default DocumentViewScreen;

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

@@ -1,7 +1,13 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Redirect } from 'expo-router'; import { Redirect } from 'expo-router';
import { createAuthClient } from '@/modules/auth/auth.client';
import { configLocalStorage } from '@/modules/config/config.local-storage'; import { configLocalStorage } from '@/modules/config/config.local-storage';
if (__DEV__) {
// eslint-disable-next-line ts/no-require-imports
require('./ReactotronConfig');
}
export default function Index() { export default function Index() {
const query = useQuery({ const query = useQuery({
queryKey: ['api-server-url'], queryKey: ['api-server-url'],
@@ -17,6 +23,11 @@ export default function Index() {
return <Redirect href="/config/server-selection" />; return <Redirect href="/config/server-selection" />;
} }
const authClient = createAuthClient({ baseUrl: query.data });
if (authClient.getCookie()) {
return <Redirect href="/(app)/(with-organizations)/(tabs)/list" />;
}
return <Redirect href="/auth/login" />; return <Redirect href="/auth/login" />;
}; };

View File

@@ -1,13 +1,14 @@
{ {
"name": "mobile", "name": "mobile",
"type": "module",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"main": "expo-router/entry", "main": "expo-router/entry",
"scripts": { "scripts": {
"dev": "pnpm start", "dev": "pnpm start",
"start": "expo start", "start": "expo start",
"android": "expo start --android", "android": "expo run:android",
"ios": "expo start --ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
@@ -28,14 +29,16 @@
"better-auth": "catalog:", "better-auth": "catalog:",
"expo": "~54.0.22", "expo": "~54.0.22",
"expo-constants": "~18.0.10", "expo-constants": "~18.0.10",
"expo-document-picker": "^14.0.7", "expo-document-picker": "^14.0.8",
"expo-file-system": "^19.0.19", "expo-file-system": "^19.0.19",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image": "~3.0.10", "expo-image": "~3.0.10",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-network": "^8.0.8",
"expo-router": "~6.0.14", "expo-router": "~6.0.14",
"expo-secure-store": "^15.0.7", "expo-secure-store": "^15.0.7",
"expo-sharing": "^14.0.7",
"expo-splash-screen": "~31.0.10", "expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8", "expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7", "expo-symbols": "~1.0.7",
@@ -46,6 +49,7 @@
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-pdf": "^7.0.3",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
@@ -59,6 +63,7 @@
"eas-cli": "^16.27.0", "eas-cli": "^16.27.0",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"reactotron-react-native": "^5.1.18",
"typescript": "catalog:", "typescript": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
} }

View File

@@ -1,12 +1,12 @@
type DateKeys = 'createdAt' | 'updatedAt' | 'deletedAt' | 'expiresAt' | 'lastTriggeredAt' | 'lastUsedAt' | 'scheduledPurgeAt'; type DateKeys = 'createdAt' | 'updatedAt' | 'deletedAt' | 'expiresAt' | 'lastTriggeredAt' | 'lastUsedAt' | 'scheduledPurgeAt';
type CoerceDate<T> = T extends string | Date export type CoerceDate<T> = T extends string | Date
? Date ? Date
: T extends string | Date | null | undefined : T extends string | Date | null | undefined
? Date | undefined ? Date | undefined
: T; : T;
type CoerceDates<T> = { export type CoerceDates<T> = {
[K in keyof T]: K extends DateKeys ? CoerceDate<T[K]> : T[K]; [K in keyof T]: K extends DateKeys ? CoerceDate<T[K]> : T[K];
}; };
@@ -41,3 +41,9 @@ export function coerceDates<T extends Record<string, unknown>>(obj: T): CoerceDa
...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: coerceDateOrUndefined(obj.scheduledPurgeAt) } : {}), ...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: coerceDateOrUndefined(obj.scheduledPurgeAt) } : {}),
} as CoerceDates<T>; } as CoerceDates<T>;
} }
export type LocalDocument = {
uri: string;
name: string;
type: string | undefined;
};

View File

@@ -0,0 +1,335 @@
import type { CoerceDates } from '@/modules/api/api.models';
import type { Document } from '@/modules/documents/documents.types';
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { router } from 'expo-router';
import * as Sharing from 'expo-sharing';
import {
Modal,
StyleSheet,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native';
import { useAuthClient } from '@/modules/api/providers/api.provider';
import { configLocalStorage } from '@/modules/config/config.local-storage';
import { fetchDocumentFile } from '@/modules/documents/documents.services';
import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
type DocumentActionSheetProps = {
visible: boolean;
document: CoerceDates<Document> | undefined;
onClose: () => void;
};
export function DocumentActionSheet({
visible,
document,
onClose,
}: DocumentActionSheetProps) {
const themeColors = useThemeColor();
const styles = createStyles({ themeColors });
const { showAlert } = useAlert();
const authClient = useAuthClient();
if (document === undefined) {
return null;
}
// Check if document can be viewed in DocumentViewerScreen
// Supported types: images (image/*) and PDFs (application/pdf)
const isViewable
= document.mimeType.startsWith('image/')
|| document.mimeType.startsWith('application/pdf');
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const handleView = async () => {
onClose();
router.push({
pathname: '/(app)/document/view',
params: {
documentId: document.id,
organizationId: document.organizationId,
},
});
};
const handleDownloadAndShare = async () => {
const baseUrl = await configLocalStorage.getApiServerBaseUrl();
if (baseUrl == null) {
showAlert({
title: 'Error',
message: 'Base URL not found',
});
return;
}
const canShare = await Sharing.isAvailableAsync();
if (!canShare) {
showAlert({
title: 'Sharing Failed',
message: 'Sharing is not available on this device. Please share the document manually.',
});
return;
}
try {
const fileUri = await fetchDocumentFile({
document,
organizationId: document.organizationId,
baseUrl,
authClient,
});
await Sharing.shareAsync(fileUri);
} catch (error) {
console.error('Error downloading document file:', error);
showAlert({
title: 'Error',
message: 'Failed to download document file',
});
}
};
// Extract MIME type subtype, fallback to full MIME type if subtype is missing
const mimeParts = document.mimeType.split('/');
const mimeSubtype = mimeParts[1];
const displayMimeType = mimeSubtype != null && mimeSubtype !== '' ? mimeSubtype : document.mimeType;
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={onClose}
>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.overlay}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
{/* Handle bar */}
<View style={styles.handleBar} />
{/* Document info */}
<View style={styles.documentInfo}>
<Text style={styles.documentName} numberOfLines={2}>
{document.name}
</Text>
{/* Document details */}
<View style={styles.detailsContainer}>
<View style={styles.detailRow}>
<MaterialCommunityIcons
name="file"
size={14}
color={themeColors.mutedForeground}
style={styles.detailIcon}
/>
<Text style={styles.detailText}>{formatFileSize(document.originalSize)}</Text>
</View>
<View style={styles.detailRow}>
<MaterialCommunityIcons
name="calendar"
size={14}
color={themeColors.mutedForeground}
style={styles.detailIcon}
/>
<Text style={styles.detailText}>{formatDate(document.createdAt.toISOString())}</Text>
</View>
<View style={styles.detailRow}>
<MaterialCommunityIcons
name="file-document-outline"
size={14}
color={themeColors.mutedForeground}
style={styles.detailIcon}
/>
<Text style={styles.detailText} numberOfLines={1}>
{displayMimeType}
</Text>
</View>
</View>
</View>
{/* Action buttons */}
<View style={styles.actions}>
{isViewable && (
<TouchableOpacity
style={styles.actionButton}
onPress={async () => {
onClose();
await handleView();
}}
activeOpacity={0.7}
>
<View style={[styles.actionIcon, styles.viewIcon]}>
<MaterialCommunityIcons
name="eye"
size={20}
color={themeColors.primary}
/>
</View>
<Text style={styles.actionText}>View</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.actionButton}
onPress={async () => {
onClose();
await handleDownloadAndShare();
}}
activeOpacity={0.7}
>
<View style={[styles.actionIcon, styles.downloadIcon]}>
<MaterialCommunityIcons
name="download"
size={20}
color={themeColors.primary}
/>
</View>
<Text style={styles.actionText}>Share</Text>
</TouchableOpacity>
</View>
{/* Cancel button */}
<TouchableOpacity
style={styles.cancelButton}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: themeColors.secondaryBackground,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingBottom: 34, // Safe area for bottom
paddingTop: 16,
},
handleBar: {
width: 40,
height: 4,
backgroundColor: themeColors.border,
borderRadius: 2,
alignSelf: 'center',
marginBottom: 16,
},
documentInfo: {
paddingHorizontal: 24,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: themeColors.border,
},
documentName: {
fontSize: 16,
fontWeight: '600',
color: themeColors.foreground,
textAlign: 'center',
marginBottom: 12,
},
detailsContainer: {
flexDirection: 'row',
justifyContent: 'center',
flexWrap: 'wrap',
gap: 16,
marginTop: 8,
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
detailIcon: {
marginRight: 2,
},
detailText: {
fontSize: 12,
color: themeColors.mutedForeground,
},
actions: {
flexDirection: 'row',
paddingHorizontal: 24,
paddingVertical: 16,
gap: 16,
},
actionButton: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
backgroundColor: themeColors.secondaryBackground,
borderRadius: 12,
},
actionIcon: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 8,
},
viewIcon: {
backgroundColor: `${themeColors.primary}15`,
},
downloadIcon: {
backgroundColor: `${themeColors.primary}15`,
},
actionText: {
fontSize: 14,
fontWeight: '500',
color: themeColors.foreground,
},
cancelButton: {
marginHorizontal: 24,
marginTop: 12,
paddingVertical: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: themeColors.border,
borderRadius: 12,
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
color: themeColors.foreground,
},
});
}

View File

@@ -0,0 +1,257 @@
import type { CoerceDates } from '@/modules/api/api.models';
import type { Document } from '@/modules/documents/documents.types';
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React from 'react';
import {
ActivityIndicator,
Image,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import Pdf from 'react-native-pdf';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useApiClient, useAuthClient } from '@/modules/api/providers/api.provider';
import { configLocalStorage } from '@/modules/config/config.local-storage';
import { fetchDocument, fetchDocumentFile } from '@/modules/documents/documents.services';
import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
type DocumentFile = {
uri: string;
doc: CoerceDates<Document>;
};
export default function DocumentViewScreen() {
const router = useRouter();
const params = useLocalSearchParams<{ documentId: string; organizationId: string }>();
const themeColors = useThemeColor();
const styles = createStyles({ themeColors });
const { showAlert } = useAlert();
const apiClient = useApiClient();
const authClient = useAuthClient();
const { documentId, organizationId } = params;
const documentQuery = useQuery({
queryKey: ['organizations', organizationId, 'documents', documentId],
queryFn: async () => {
if (organizationId == null || documentId == null) {
throw new Error('Organization ID and Document ID are required');
}
return fetchDocument({ organizationId, documentId, apiClient });
},
enabled: organizationId != null && documentId != null,
});
const documentFileQuery = useQuery({
queryKey: ['organizations', organizationId, 'documents', documentId, 'file'],
queryFn: async () => {
if (documentQuery.data == null) {
throw new Error('Document not loaded');
}
const baseUrl = await configLocalStorage.getApiServerBaseUrl();
if (baseUrl == null) {
throw new Error('Base URL not found');
}
const fileUri = await fetchDocumentFile({
document: documentQuery.data.document,
organizationId,
baseUrl,
authClient,
});
return {
uri: fileUri,
doc: documentQuery.data.document,
} as DocumentFile;
},
enabled: documentQuery.isSuccess && documentQuery.data != null,
});
const renderHeader = (documentName: string) => {
return (
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<MaterialCommunityIcons
name="close"
size={24}
color={themeColors.foreground}
/>
</TouchableOpacity>
<Text style={styles.headerTitle} numberOfLines={1}>
{documentName}
</Text>
<View style={styles.headerSpacer} />
</View>
);
};
const renderDocumentFile = (file: DocumentFile) => {
if (file.doc.mimeType.startsWith('image/')) {
return (
<Image
source={{ uri: file.uri }}
style={styles.pdfViewer}
/>
);
}
if (file.doc.mimeType.startsWith('application/pdf')) {
return (
<Pdf
source={{ uri: file.uri, cache: true }}
style={styles.pdfViewer}
onError={(error) => {
console.error('PDF error:', error);
showAlert({
title: 'Error',
message: 'Failed to load PDF',
});
}}
enablePaging={true}
horizontal={false}
enableAnnotationRendering={true}
fitPolicy={0}
spacing={10}
/>
);
}
return <View style={styles.pdfViewer} />;
};
const isLoading = documentQuery.isLoading || documentFileQuery.isLoading;
const error = documentQuery.error ?? documentFileQuery.error;
const documentFile = documentFileQuery.data;
const documentName = documentFile?.doc.name ?? 'Document';
return (
<SafeAreaView style={styles.container}>
{renderHeader(documentName)}
{isLoading
? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={themeColors.primary} />
<Text style={styles.loadingText}>Loading document...</Text>
</View>
)
: error != null
? (
<View style={styles.errorContainer}>
<MaterialCommunityIcons
name="file-pdf-box"
size={64}
color={themeColors.mutedForeground}
/>
<Text style={styles.errorText}>Failed to load document</Text>
<TouchableOpacity
style={styles.errorButton}
onPress={() => {
void documentQuery.refetch();
}}
>
<Text style={styles.errorButtonText}>Retry</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.errorButton}
onPress={() => router.back()}
>
<Text style={styles.errorButtonText}>Go Back</Text>
</TouchableOpacity>
</View>
)
: documentFile != null
? (
<View style={styles.pdfContainer}>
{renderDocumentFile(documentFile)}
</View>
)
: null}
</SafeAreaView>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: themeColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: themeColors.border,
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: themeColors.secondaryBackground,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
flex: 1,
fontSize: 18,
fontWeight: 'bold',
color: themeColors.foreground,
marginHorizontal: 16,
},
headerSpacer: {
width: 40,
},
pdfContainer: {
flex: 1,
backgroundColor: themeColors.background,
},
pdfViewer: {
flex: 1,
width: '100%',
height: '100%',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: themeColors.mutedForeground,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
errorText: {
fontSize: 18,
color: themeColors.foreground,
marginTop: 16,
marginBottom: 24,
},
errorButton: {
paddingHorizontal: 24,
paddingVertical: 16,
backgroundColor: themeColors.secondaryBackground,
borderRadius: 12,
marginTop: 16,
},
errorButtonText: {
fontSize: 16,
fontWeight: '600',
color: themeColors.primary,
},
});
}

View File

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

View File

@@ -1,28 +1,37 @@
import type { ApiClient } from '../api/api.client'; import type { ApiClient } from '../api/api.client';
import type { CoerceDates, LocalDocument } from '../api/api.models';
import type { AuthClient } from '../auth/auth.client';
import type { Document } from './documents.types'; import type { Document } from './documents.types';
import * as FileSystem from 'expo-file-system/legacy';
import { coerceDates } from '../api/api.models'; 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(); const formData = new FormData();
Object.entries(pojo).forEach(([key, value]) => formData.append(key, value)); Object.entries(pojo).forEach(([key, value]) => formData.append(key, value));
return formData; return formData;
} }
export async function uploadDocument({ export async function uploadDocument({
file, file,
organizationId, organizationId,
apiClient, apiClient,
}: { }: {
file: Blob; file: LocalDocument;
organizationId: string; organizationId: string;
apiClient: ApiClient; apiClient: ApiClient;
}) { }) {
const { document } = await apiClient<{ document: Document }>({ const { document } = await apiClient<{ document: Document }>({
method: 'POST', method: 'POST',
path: `/api/organizations/${organizationId}/documents`, 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 { return {
@@ -72,3 +81,53 @@ export async function fetchOrganizationDocuments({
throw error; throw error;
} }
} }
export async function fetchDocument({
organizationId,
documentId,
apiClient,
}: {
organizationId: string;
documentId: string;
apiClient: ApiClient;
}) {
const { document } = await apiClient<{ document: Document }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents/${documentId}`,
});
return {
document: coerceDates(document),
};
}
export async function fetchDocumentFile({
document,
organizationId,
baseUrl,
authClient,
}: {
document: CoerceDates<Document>;
organizationId: string;
baseUrl: string;
authClient: AuthClient;
}) {
const cookies = authClient.getCookie();
const uri = `${baseUrl}/api/organizations/${organizationId}/documents/${document.id}/file`;
const headers = {
'Cookie': cookies,
'Content-Type': 'application/json',
};
// Use cacheDirectory for better app compatibility
const fileUri = `${FileSystem.cacheDirectory}${document.name}`;
// Download the file with authentication headers
const downloadResult = await FileSystem.downloadAsync(uri, fileUri, {
headers,
});
if (downloadResult.status === 200) {
return downloadResult.uri;
} else {
throw new Error(`Download failed with status: ${downloadResult.status}`);
}
}

View File

@@ -1,3 +1,5 @@
import type { Document } from '../documents.types';
import type { CoerceDates } from '@/modules/api/api.models';
import type { ThemeColors } from '@/modules/ui/theme.constants'; import type { ThemeColors } from '@/modules/ui/theme.constants';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
@@ -7,10 +9,12 @@ import {
RefreshControl, RefreshControl,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useApiClient } from '@/modules/api/providers/api.provider'; import { useApiClient } from '@/modules/api/providers/api.provider';
import { DocumentActionSheet } from '@/modules/documents-actions/components/document-action-sheet';
import { OrganizationPickerButton } from '@/modules/organizations/components/organization-picker-button'; import { OrganizationPickerButton } from '@/modules/organizations/components/organization-picker-button';
import { OrganizationPickerDrawer } from '@/modules/organizations/components/organization-picker-drawer'; import { OrganizationPickerDrawer } from '@/modules/organizations/components/organization-picker-drawer';
import { useOrganizations } from '@/modules/organizations/organizations.provider'; import { useOrganizations } from '@/modules/organizations/organizations.provider';
@@ -22,6 +26,7 @@ export function DocumentsListScreen() {
const themeColors = useThemeColor(); const themeColors = useThemeColor();
const apiClient = useApiClient(); const apiClient = useApiClient();
const { currentOrganizationId, isLoading: isLoadingOrganizations } = useOrganizations(); const { currentOrganizationId, isLoading: isLoadingOrganizations } = useOrganizations();
const [onDocumentActionSheet, setOnDocumentActionSheet] = useState<CoerceDates<Document> | undefined>(undefined);
const [isDrawerVisible, setIsDrawerVisible] = useState(false); const [isDrawerVisible, setIsDrawerVisible] = useState(false);
const pagination = { pageIndex: 0, pageSize: 20 }; const pagination = { pageIndex: 0, pageSize: 20 };
@@ -75,6 +80,13 @@ export function DocumentsListScreen() {
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
{onDocumentActionSheet && (
<DocumentActionSheet
visible={true}
document={onDocumentActionSheet}
onClose={() => setOnDocumentActionSheet(undefined)}
/>
)}
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>Documents</Text> <Text style={styles.title}>Documents</Text>
<OrganizationPickerButton onPress={() => setIsDrawerVisible(true)} /> <OrganizationPickerButton onPress={() => setIsDrawerVisible(true)} />
@@ -91,6 +103,7 @@ export function DocumentsListScreen() {
data={documentsQuery.data?.documents ?? []} data={documentsQuery.data?.documents ?? []}
keyExtractor={item => item.id} keyExtractor={item => item.id}
renderItem={({ item }) => ( renderItem={({ item }) => (
<TouchableOpacity onPress={() => setOnDocumentActionSheet(item)}>
<View style={styles.documentCard}> <View style={styles.documentCard}>
<View style={{ backgroundColor: themeColors.muted, padding: 10, borderRadius: 6, marginRight: 12 }}> <View style={{ backgroundColor: themeColors.muted, padding: 10, borderRadius: 6, marginRight: 12 }}>
<Icon name="file-text" size={24} color={themeColors.primary} /> <Icon name="file-text" size={24} color={themeColors.primary} />
@@ -121,9 +134,9 @@ export function DocumentsListScreen() {
</View> </View>
)} )}
</View> </View>
</View> </View>
</View> </View>
</TouchableOpacity>
)} )}
ListEmptyComponent={( ListEmptyComponent={(
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>

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

@@ -1,6 +1,7 @@
{ {
"extends": "expo/tsconfig.base", "extends": "expo/tsconfig.base",
"compilerOptions": { "compilerOptions": {
"jsx": "react-native",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]

View File

@@ -0,0 +1,720 @@
import type { TranslationsDictionary } from '@/modules/i18n/locales.types';
export const translations: Partial<TranslationsDictionary> = {
// Authentication
'auth.request-password-reset.title': '重置您的密码',
'auth.request-password-reset.description': '输入您的电子邮件以重置密码。',
'auth.request-password-reset.requested': '如果该电子邮件有对应账户,我们将发送重置密码的邮件。',
'auth.request-password-reset.back-to-login': '返回登录页面',
'auth.request-password-reset.form.email.label': '电子邮件',
'auth.request-password-reset.form.email.placeholder': '示例: ada@papra.app',
'auth.request-password-reset.form.email.required': '请输入您的电子邮件地址',
'auth.request-password-reset.form.email.invalid': '该电子邮件地址无效',
'auth.request-password-reset.form.submit': '请求重置密码',
'auth.reset-password.title': '重置您的密码',
'auth.reset-password.description': '输入您的新密码以重置密码。',
'auth.reset-password.reset': '您的密码已重置。',
'auth.reset-password.back-to-login': '返回登录',
'auth.reset-password.form.new-password.label': '新密码',
'auth.reset-password.form.new-password.placeholder': '示例: **********',
'auth.reset-password.form.new-password.required': '请输入您的新密码',
'auth.reset-password.form.new-password.min-length': '密码长度至少为 {{ minLength }} 个字符',
'auth.reset-password.form.new-password.max-length': '密码长度不能超过 {{ maxLength }} 个字符',
'auth.reset-password.form.submit': '重置密码',
'auth.email-provider.open': '打开 {{ provider }}',
'auth.login.title': '登录 Papra',
'auth.login.description': '输入您的电子邮件或使用社交账户登录访问您的 Papra 账户。',
'auth.login.login-with-provider': '使用 {{ provider }} 登录',
'auth.login.no-account': '没有账户?',
'auth.login.register': '注册',
'auth.login.form.email.label': '电子邮件',
'auth.login.form.email.placeholder': '示例: ada@papra.app',
'auth.login.form.email.required': '请输入您的电子邮件地址',
'auth.login.form.email.invalid': '该电子邮件地址无效',
'auth.login.form.password.label': '密码',
'auth.login.form.password.placeholder': '设置密码',
'auth.login.form.password.required': '请输入您的密码',
'auth.login.form.remember-me.label': '记住我',
'auth.login.form.forgot-password.label': '忘记密码?',
'auth.login.form.submit': '登录',
'auth.register.title': '注册 Papra',
'auth.register.description': '创建一个账户以开始使用 Papra。',
'auth.register.register-with-email': '使用电子邮件注册',
'auth.register.register-with-provider': '使用 {{ provider }} 注册',
'auth.register.providers.google': 'Google',
'auth.register.providers.github': 'GitHub',
'auth.register.have-account': '已有账户?',
'auth.register.login': '登录',
'auth.register.registration-disabled.title': '注册被禁用',
'auth.register.registration-disabled.description': '当前 Papra 实例已禁用新账户的创建。只有已有账户的用户可以登录。如果您认为这是错误,请联系该实例的管理员。',
'auth.register.form.email.label': '电子邮件',
'auth.register.form.email.placeholder': '示例: ada@papra.app',
'auth.register.form.email.required': '请输入您的电子邮件地址',
'auth.register.form.email.invalid': '该电子邮件地址无效',
'auth.register.form.password.label': '密码',
'auth.register.form.password.placeholder': '设置密码',
'auth.register.form.password.required': '请输入您的密码',
'auth.register.form.password.min-length': '密码长度至少为 {{ minLength }} 个字符',
'auth.register.form.password.max-length': '密码长度不能超过 {{ maxLength }} 个字符',
'auth.register.form.name.label': '姓名',
'auth.register.form.name.placeholder': '示例: Ada Lovelace',
'auth.register.form.name.required': '请输入您的姓名',
'auth.register.form.name.max-length': '姓名长度不能超过 {{ maxLength }} 个字符',
'auth.register.form.submit': '注册',
'auth.email-validation-required.title': '验证您的电子邮件',
'auth.email-validation-required.description': '一封验证邮件已发送到您的电子邮件地址。请通过点击邮件中的链接来验证您的电子邮件地址。',
'auth.email-verification.success.title': '电子邮件已验证',
'auth.email-verification.success.description': '您的电子邮件已成功验证。您现在可以登录您的账户。',
'auth.email-verification.success.login': '前往登录',
'auth.email-verification.error.title': '验证失败',
'auth.email-verification.error.description': '验证链接已过期或无效。请通过登录请求新的验证邮件。',
'auth.email-verification.error.back': '返回登录',
'auth.legal-links.description': '继续即表示您已了解并同意{{ terms }}和{{ privacy }}。',
'auth.legal-links.terms': '服务条款',
'auth.legal-links.privacy': '隐私政策',
'auth.no-auth-provider.title': '无身份验证提供者',
'auth.no-auth-provider.description': '此 Papra 实例未启用任何身份验证提供者。请联系该实例的管理员以启用它们。',
// User settings
'user.settings.title': '用户设置',
'user.settings.description': '在此管理您的账户设置。',
'user.settings.email.title': '电子邮件地址',
'user.settings.email.description': '您的电子邮件地址无法更改。',
'user.settings.email.label': '电子邮件地址',
'user.settings.name.title': '全名',
'user.settings.name.description': '您的全名会显示给其他组织成员。',
'user.settings.name.label': '全名',
'user.settings.name.placeholder': '例如: 张三',
'user.settings.name.update': '更新姓名',
'user.settings.name.updated': '您的全名已更新',
'user.settings.logout.title': '登出',
'user.settings.logout.description': '从您的账户登出。您可以稍后再次登录。',
'user.settings.logout.button': '登出',
// Organizations
'organizations.list.title': '您的组织',
'organizations.list.description': '组织是一种将您的文档分组并管理访问权限的方式。您可以创建多个组织并邀请您的团队成员进行协作。',
'organizations.list.create-new': '创建新组织',
'organizations.list.back': '返回组织列表',
'organizations.list.deleted.title': '已删除的组织',
'organizations.list.deleted.description': '已删除的组织将在 {{ days }} 天内保留,之后将被永久删除。您可以在此期间恢复它们。',
'organizations.list.deleted.empty': '没有已删除的组织',
'organizations.list.deleted.empty-description': '当您删除一个组织时,它将在此处显示 {{ days }} 天,然后被永久删除。',
'organizations.list.deleted.restore': '恢复',
'organizations.list.deleted.restore-success': '组织已成功恢复',
'organizations.list.deleted.restore-confirm.title': '恢复组织',
'organizations.list.deleted.restore-confirm.message': '您确定要恢复此组织吗?它将被移回您的活动组织列表。',
'organizations.list.deleted.restore-confirm.confirm-button': '恢复组织',
'organizations.list.deleted.deleted-at': '已删除 {{ date }}',
'organizations.list.deleted.purge-at': '将于 {{ date }} 被永久删除',
'organizations.list.deleted.days-remaining': '(剩余 {{ daysUntilPurge, =1:{daysUntilPurge} 天, {daysUntilPurge} 天 }}',
'organizations.details.no-documents.title': '没有文档',
'organizations.details.no-documents.description': '该组织中尚无文档。您可以开始上传一些文档。',
'organizations.details.upload-documents': '上传文档',
'organizations.details.documents-count': '共计文档',
'organizations.details.total-size': '总大小',
'organizations.details.latest-documents': '最新导入的文档',
'organizations.create.title': '创建新组织',
'organizations.create.description': '您的文档将按组织分组。您可以创建多个组织来分隔您的文档,例如,个人和工作文档。',
'organizations.create.back': '返回',
'organizations.create.error.max-count-reached': '您已达到可创建的组织数量上限,如果需要创建更多组织,请联系支持。',
'organizations.create.form.name.label': '组织名称',
'organizations.create.form.name.placeholder': '例如: Acme Inc.',
'organizations.create.form.name.required': '请输入组织名称',
'organizations.create.form.submit': '创建组织',
'organizations.create.success': '组织创建成功',
'organizations.create-first.title': '创建您的组织',
'organizations.create-first.description': '您的文档将按组织分组。您可以创建多个组织来分隔您的文档,例如,个人和工作文档。',
'organizations.create-first.default-name': '我的组织',
'organizations.create-first.user-name': '{{ name }}的组织',
'organization.settings.title': '组织设置',
'organization.settings.page.title': '组织设置',
'organization.settings.page.description': '在此管理您的组织设置。',
'organization.settings.name.title': '组织名称',
'organization.settings.name.update': '更新名称',
'organization.settings.name.placeholder': '例如: Acme Inc.',
'organization.settings.name.updated': '组织名称已更新',
'organization.settings.subscription.title': '订阅',
'organization.settings.subscription.description': '管理您的账单、发票和付款方式。',
'organization.settings.subscription.manage': '管理订阅',
'organization.settings.subscription.error': '获取客户门户 URL 失败',
'organization.settings.delete.title': '删除组织',
'organization.settings.delete.description': '删除此组织将永久移除与其相关的所有数据。',
'organization.settings.delete.confirm.title': '删除组织',
'organization.settings.delete.confirm.message': '您确定要删除此组织吗?该组织将被标记为删除,并在 {{ days }} 天后永久移除。在此期间,您可以从您的组织列表中恢复它。所有文档和数据将在此延迟后永久删除。',
'organization.settings.delete.confirm.confirm-button': '删除组织',
'organization.settings.delete.confirm.cancel-button': '取消',
'organization.settings.delete.success': '组织已删除',
'organization.settings.delete.only-owner': '只有组织所有者可以删除此组织。',
'organization.settings.delete.has-active-subscription': '无法删除有有效订阅的组织,请先取消您的订阅。',
'organization.usage.page.title': '使用情况',
'organization.usage.page.description': '查看您组织的当前使用情况和限制。',
'organization.usage.storage.title': '文档存储',
'organization.usage.storage.description': '您的文档使用的总存储空间',
'organization.usage.intake-emails.title': '接收邮箱',
'organization.usage.intake-emails.description': '接收邮箱地址的数量',
'organization.usage.members.title': '成员',
'organization.usage.members.description': '组织中的成员数量',
'organization.usage.unlimited': '无限制',
'organizations.members.title': '成员',
'organizations.members.description': '管理您的组织成员',
'organizations.members.invite-member': '邀请成员',
'organizations.members.invite-member-disabled-tooltip': '只有管理员或所有者可以邀请成员加入组织',
'organizations.members.remove-from-organization': '从组织中移除',
'organizations.members.role': '角色',
'organizations.members.roles.owner': '所有者',
'organizations.members.roles.admin': '管理员',
'organizations.members.roles.member': '成员',
'organizations.members.delete.confirm.title': '移除成员',
'organizations.members.delete.confirm.message': '您确定要将此成员从组织中移除吗?',
'organizations.members.delete.confirm.confirm-button': '移除',
'organizations.members.delete.confirm.cancel-button': '取消',
'organizations.members.delete.success': '成员已从组织中移除',
'organizations.members.update-role.success': '成员角色已更新',
'organizations.members.table.headers.name': '姓名',
'organizations.members.table.headers.email': '电子邮件',
'organizations.members.table.headers.role': '角色',
'organizations.members.table.headers.created': '创建时间',
'organizations.members.table.headers.actions': '操作',
'organizations.invite-member.title': '邀请成员',
'organizations.invite-member.description': '邀请成员加入您的组织',
'organizations.invite-member.form.email.label': '电子邮件',
'organizations.invite-member.form.email.placeholder': '例如: ada@papra.app',
'organizations.invite-member.form.email.required': '请输入有效的电子邮件地址',
'organizations.invite-member.form.role.label': '角色',
'organizations.invite-member.form.submit': '邀请加入组织',
'organizations.invite-member.success.message': '成员已被邀请',
'organizations.invite-member.success.description': '该电子邮件已被邀请加入组织。',
'organizations.invite-member.error.message': '邀请成员失败',
'organizations.invitations.title': '邀请',
'organizations.invitations.description': '管理您的组织邀请',
'organizations.invitations.list.cta': '邀请成员',
'organizations.invitations.list.empty.title': '没有待处理的邀请',
'organizations.invitations.list.empty.description': '您还没有被邀请加入任何组织。',
'organizations.invitations.status.pending': '待处理',
'organizations.invitations.status.accepted': '已接受',
'organizations.invitations.status.rejected': '已拒绝',
'organizations.invitations.status.expired': '已过期',
'organizations.invitations.status.cancelled': '已取消',
'organizations.invitations.resend': '重新发送邀请',
'organizations.invitations.cancel.title': '取消邀请',
'organizations.invitations.cancel.description': '您确定要取消此邀请吗?',
'organizations.invitations.cancel.confirm': '取消邀请',
'organizations.invitations.cancel.cancel': '取消',
'organizations.invitations.resend.title': '重新发送邀请',
'organizations.invitations.resend.description': '您确定要重新发送此邀请吗?这将向收件人发送一封新电子邮件。',
'organizations.invitations.resend.confirm': '重新发送邀请',
'organizations.invitations.resend.cancel': '取消',
'invitations.list.title': '邀请',
'invitations.list.description': '管理您的组织邀请',
'invitations.list.empty.title': '没有待处理的邀请',
'invitations.list.empty.description': '您还没有被邀请加入任何组织。',
'invitations.list.headers.organization': '组织',
'invitations.list.headers.status': '状态',
'invitations.list.headers.created': '创建时间',
'invitations.list.headers.actions': '操作',
'invitations.list.actions.accept': '接受',
'invitations.list.actions.reject': '拒绝',
'invitations.list.actions.accept.success.message': '邀请已接受',
'invitations.list.actions.accept.success.description': '该邀请已被接受。',
'invitations.list.actions.reject.success.message': '邀请已拒绝',
'invitations.list.actions.reject.success.description': '该邀请已被拒绝。',
// Documents
'documents.list.title': '文档',
'documents.list.no-documents.title': '没有文档',
'documents.list.no-documents.description': '该组织中尚无文档。您可以开始上传一些文档。',
'documents.list.no-results': '未找到文档',
'documents.list.table.headers.file-name': '文件名',
'documents.list.table.headers.created': '创建时间',
'documents.list.table.headers.deleted': '删除时间',
'documents.list.table.headers.actions': '操作',
'documents.list.table.headers.tags': '标签',
'documents.tabs.info': '信息',
'documents.tabs.content': '内容',
'documents.tabs.activity': '活动',
'documents.deleted.message': '该文档已被删除,将在 {{ days }} 天内被永久移除。',
'documents.actions.download': '下载',
'documents.actions.open-in-new-tab': '在新标签页中打开',
'documents.actions.restore': '恢复',
'documents.actions.delete': '删除',
'documents.actions.edit': '编辑',
'documents.actions.cancel': '取消',
'documents.actions.save': '保存',
'documents.actions.saving': '保存中...',
'documents.content.alert': '文档内容在上传时自动提取,仅用于搜索和索引。',
'documents.content.empty-placeholder': '该文档没有提取的内容,您可以在此手动设置。',
'documents.info.id': 'ID',
'documents.info.name': '名称',
'documents.info.type': '类型',
'documents.info.size': '大小',
'documents.info.created-at': '创建时间',
'documents.info.updated-at': '更新时间',
'documents.info.never': '从不',
'documents.rename.title': '重命名文档',
'documents.rename.form.name.label': '名称',
'documents.rename.form.name.placeholder': '示例:发票 2024',
'documents.rename.form.name.required': '请输入文档名称',
'documents.rename.form.name.max-length': '名称必须少于 255 个字符',
'documents.rename.form.submit': '重命名文档',
'documents.rename.success': '文档重命名成功',
'documents.rename.cancel': '取消',
'import-documents.title.error': '{{ count }} 个文档导入失败',
'import-documents.title.success': '{{ count }} 个文档已导入',
'import-documents.title.pending': '{{ count }} / {{ total }} 个文档已导入',
'import-documents.title.none': '导入文档',
'import-documents.no-import-in-progress': '没有正在进行的文档导入',
'documents.deleted.title': '已删除的文档',
'documents.deleted.empty.title': '没有已删除的文档',
'documents.deleted.empty.description': '您没有已删除的文档。被删除的文档将在 {{ days }} 天内移至回收站。',
'documents.deleted.retention-notice': '所有已删除的文档将在回收站中保存 {{ days }} 天。超过此期限,文档将被永久删除,且无法恢复。',
'documents.deleted.deleted-at': '删除时间',
'documents.deleted.restoring': '恢复中...',
'documents.deleted.deleting': '删除中...',
'documents.preview.unknown-file-type': '此文件类型暂无预览',
'documents.preview.binary-file': '该文件似乎是二进制文件,无法以文本形式显示',
'trash.delete-all.button': '全部删除',
'trash.delete-all.confirm.title': '永久删除所有文档?',
'trash.delete-all.confirm.description': '您确定要永久删除回收站中的所有文档吗?此操作无法撤销。',
'trash.delete-all.confirm.label': '删除',
'trash.delete-all.confirm.cancel': '取消',
'trash.delete.button': '删除',
'trash.delete.confirm.title': '永久删除文档?',
'trash.delete.confirm.description': '您确定要永久删除回收站中的此文档吗?此操作无法撤销。',
'trash.delete.confirm.label': '删除',
'trash.delete.confirm.cancel': '取消',
'trash.deleted.success.title': '文档已删除',
'trash.deleted.success.description': '该文档已被永久删除。',
'activity.document.created': '文档已创建',
'activity.document.updated.single': '{{ field }} 已更新',
'activity.document.updated.multiple': '{{ fields }} 已更新',
'activity.document.updated': '文档已更新',
'activity.document.deleted': '文档已被删除',
'activity.document.restored': '文档已恢复',
'activity.document.tagged': '已添加标签 {{ tag }}',
'activity.document.untagged': '已移除标签 {{ tag }}',
'activity.document.user.name': '由 {{ name }}',
'activity.load-more': '加载更多',
'activity.no-more-activities': '该文档没有更多活动记录',
// Tags
'tags.no-tags.title': '暂无标签',
'tags.no-tags.description': '该组织尚无标签。标签用于对文档进行分类,便于查找和组织。',
'tags.no-tags.create-tag': '创建标签',
'tags.title': '文档标签',
'tags.description': '标签用于对文档进行分类,便于查找和组织。',
'tags.create': '创建标签',
'tags.update': '更新标签',
'tags.delete': '删除标签',
'tags.delete.confirm.title': '删除标签',
'tags.delete.confirm.message': '确定要删除此标签吗?删除后该标签将从所有文档中移除。',
'tags.delete.confirm.confirm-button': '删除',
'tags.delete.confirm.cancel-button': '取消',
'tags.delete.success': '标签已删除',
'tags.create.success': '标签 "{{ name }}" 创建成功。',
'tags.update.success': '标签 "{{ name }}" 更新成功。',
'tags.form.name.label': '名称',
'tags.form.name.placeholder': '例如:合同',
'tags.form.name.required': '请输入标签名称',
'tags.form.name.max-length': '标签名称必须少于 64 个字符',
'tags.form.color.label': '颜色',
'tags.form.color.required': '请选择颜色',
'tags.form.color.invalid': '十六进制颜色格式不正确。',
'tags.form.description.label': '描述',
'tags.form.description.optional': '(可选)',
'tags.form.description.placeholder': '例如:公司签署的所有合同',
'tags.form.description.max-length': '描述必须少于 256 个字符',
'tags.form.no-description': '无描述',
'tags.table.headers.tag': '标签',
'tags.table.headers.description': '描述',
'tags.table.headers.documents': '文档',
'tags.table.headers.created': '创建时间',
'tags.table.headers.actions': '操作',
// Tagging rules
'tagging-rules.field.name': '文档名称',
'tagging-rules.field.content': '文档内容',
'tagging-rules.operator.equals': '等于',
'tagging-rules.operator.not-equals': '不等于',
'tagging-rules.operator.contains': '包含',
'tagging-rules.operator.not-contains': '不包含',
'tagging-rules.operator.starts-with': '开始于',
'tagging-rules.operator.ends-with': '结束于',
'tagging-rules.list.title': '标签规则',
'tagging-rules.list.description': '管理组织的标签规则,根据您定义的条件自动为文档打标签。',
'tagging-rules.list.demo-warning': '注意:此为演示环境(无服务器),标签规则不会应用于新添加的文档。',
'tagging-rules.list.no-tagging-rules.title': '暂无标签规则',
'tagging-rules.list.no-tagging-rules.description': '创建标签规则,根据设定条件自动为添加的文档打标签。',
'tagging-rules.list.no-tagging-rules.create-tagging-rule': '创建标签规则',
'tagging-rules.list.card.no-conditions': '无条件',
'tagging-rules.list.card.one-condition': '1 条条件',
'tagging-rules.list.card.conditions': '{{ count }} 条条件',
'tagging-rules.list.card.delete': '删除规则',
'tagging-rules.list.card.edit': '编辑规则',
'tagging-rules.create.title': '创建标签规则',
'tagging-rules.create.success': '标签规则创建成功',
'tagging-rules.create.error': '创建标签规则失败',
'tagging-rules.create.submit': '创建规则',
'tagging-rules.form.name.label': '名称',
'tagging-rules.form.name.placeholder': '例如:为发票打标签',
'tagging-rules.form.name.min-length': '请输入规则名称',
'tagging-rules.form.name.max-length': '名称必须少于 64 个字符',
'tagging-rules.form.description.label': '描述',
'tagging-rules.form.description.placeholder': '例如:名称中包含 \'invoice\' 的文档将被打标签',
'tagging-rules.form.description.max-length': '描述必须少于 256 个字符',
'tagging-rules.form.conditions.label': '条件',
'tagging-rules.form.conditions.description': '定义规则适用的条件。若无条件,规则将应用于所有文档',
'tagging-rules.form.conditions.add-condition': '添加条件',
'tagging-rules.form.conditions.connector.when': '当',
'tagging-rules.form.conditions.connector.and': '且',
'tagging-rules.form.conditions.connector.or': '或',
'tagging-rules.condition-match-mode.all': '所有条件都需匹配',
'tagging-rules.condition-match-mode.any': '任一条件匹配即可',
'tagging-rules.form.conditions.no-conditions.title': '无条件',
'tagging-rules.form.conditions.no-conditions.description': '您未为该规则添加条件。此规则将对所有文档应用其标签。',
'tagging-rules.form.conditions.no-conditions.confirm': '在无条件下应用规则',
'tagging-rules.form.conditions.no-conditions.cancel': '取消',
'tagging-rules.form.conditions.value.placeholder': '例如invoice',
'tagging-rules.form.conditions.value.min-length': '请输入条件值',
'tagging-rules.form.tags.label': '标签',
'tagging-rules.form.tags.description': '选择要应用于匹配条件的文档的标签',
'tagging-rules.form.tags.min-length': '至少需要选择一个标签',
'tagging-rules.form.tags.add-tag': '创建标签',
'tagging-rules.form.submit': '创建规则',
'tagging-rules.update.title': '更新标签规则',
'tagging-rules.update.error': '更新标签规则失败',
'tagging-rules.update.submit': '更新规则',
'tagging-rules.update.cancel': '取消',
'tagging-rules.apply.button': '应用于现有文档',
'tagging-rules.apply.confirm.title': '将规则应用于现有文档?',
'tagging-rules.apply.confirm.description': '这将检查组织内的所有现有文档,并对匹配条件的文档应用标签。处理将在后台进行。',
'tagging-rules.apply.confirm.button': '应用规则',
'tagging-rules.apply.success': '规则应用已在后台启动',
'tagging-rules.apply.error': '启动规则应用失败',
'tagging-rules.apply.processing': '开始中...',
// Intake emails
'intake-emails.title': '接收邮箱',
'intake-emails.description': '接收邮箱地址用于将电子邮件自动导入到 Papra。只需将邮件转发至接收地址其附件将被添加到组织的文档中。',
'intake-emails.disabled.title': '接收邮箱已禁用',
'intake-emails.disabled.description': '此实例已禁用接收邮箱。请联系管理员以启用。更多信息请参阅 {{ documentation }}。',
'intake-emails.disabled.documentation': '文档',
'intake-emails.info': '只有来自允许来源且已启用的接收邮箱的邮件会被处理。您可以随时启用或禁用接收邮箱。',
'intake-emails.empty.title': '暂无接收邮箱',
'intake-emails.empty.description': '生成接收地址以便轻松导入邮件附件。',
'intake-emails.empty.generate': '生成接收邮箱',
'intake-emails.count': '组织共有 {{ count }} 个接收邮箱',
'intake-emails.new': '新建接收邮箱',
'intake-emails.disabled-label': '(已禁用)',
'intake-emails.no-origins': '无允许的发件来源',
'intake-emails.allowed-origins': '允许来自 {{ count }} 个地址',
'intake-emails.actions.enable': '启用',
'intake-emails.actions.disable': '禁用',
'intake-emails.actions.manage-origins': '管理来源地址',
'intake-emails.actions.delete': '删除',
'intake-emails.delete.confirm.title': '删除接收邮箱?',
'intake-emails.delete.confirm.message': '确定要删除此接收邮箱吗?此操作不可撤销。',
'intake-emails.delete.confirm.confirm-button': '删除接收邮箱',
'intake-emails.delete.confirm.cancel-button': '取消',
'intake-emails.delete.success': '接收邮箱已删除',
'intake-emails.create.success': '接收邮箱已创建',
'intake-emails.update.success.enabled': '接收邮箱已启用',
'intake-emails.update.success.disabled': '接收邮箱已禁用',
'intake-emails.allowed-origins.title': '允许的来源',
'intake-emails.allowed-origins.description': '只有来自这些来源并发送到 {{ email }} 的邮件会被处理。若未指定来源,所有邮件将被丢弃。',
'intake-emails.allowed-origins.add.label': '添加允许的来源邮箱',
'intake-emails.allowed-origins.add.placeholder': '例如ada@papra.app',
'intake-emails.allowed-origins.add.button': '添加',
'intake-emails.allowed-origins.add.error.exists': '该邮箱已在此接收邮箱的允许来源列表中',
// API keys
'api-keys.permissions.select-all': '全选',
'api-keys.permissions.deselect-all': '取消全选',
'api-keys.permissions.organizations.title': '组织',
'api-keys.permissions.organizations.organizations:create': '创建组织',
'api-keys.permissions.organizations.organizations:read': '读取组织',
'api-keys.permissions.organizations.organizations:update': '更新组织',
'api-keys.permissions.organizations.organizations:delete': '删除组织',
'api-keys.permissions.documents.title': '文档',
'api-keys.permissions.documents.documents:create': '创建文档',
'api-keys.permissions.documents.documents:read': '读取文档',
'api-keys.permissions.documents.documents:update': '更新文档',
'api-keys.permissions.documents.documents:delete': '删除文档',
'api-keys.permissions.tags.title': '标签',
'api-keys.permissions.tags.tags:create': '创建标签',
'api-keys.permissions.tags.tags:read': '读取标签',
'api-keys.permissions.tags.tags:update': '更新标签',
'api-keys.permissions.tags.tags:delete': '删除标签',
'api-keys.create.title': '创建 API 密钥',
'api-keys.create.description': '创建新的 API 密钥以访问 Papra API。',
'api-keys.create.success': 'API 密钥创建成功。',
'api-keys.create.back': '返回 API 密钥',
'api-keys.create.form.name.label': '名称',
'api-keys.create.form.name.placeholder': '例如:我的 API 密钥',
'api-keys.create.form.name.required': '请输入 API 密钥名称',
'api-keys.create.form.permissions.label': '权限',
'api-keys.create.form.permissions.required': '请至少选择一个权限',
'api-keys.create.form.submit': '创建 API 密钥',
'api-keys.create.created.title': 'API 密钥已创建',
'api-keys.create.created.description': 'API 密钥已创建。请妥善保存,后续将无法再次查看。',
'api-keys.list.title': 'API 密钥',
'api-keys.list.description': '在此管理您的 API 密钥。',
'api-keys.list.create': '创建 API 密钥',
'api-keys.list.empty.title': '暂无 API 密钥',
'api-keys.list.empty.description': '创建 API 密钥以访问 Papra API。',
'api-keys.list.card.last-used': '最后使用',
'api-keys.list.card.never': '从未',
'api-keys.list.card.created': '创建时间',
'api-keys.delete.success': 'API 密钥已删除',
'api-keys.delete.confirm.title': '删除 API 密钥',
'api-keys.delete.confirm.message': '确定要删除此 API 密钥吗?此操作不可撤销。',
'api-keys.delete.confirm.confirm-button': '删除',
'api-keys.delete.confirm.cancel-button': '取消',
// Webhooks
'webhooks.list.title': 'Webhook',
'webhooks.list.description': '管理组织的 Webhook',
'webhooks.list.empty.title': '暂无 Webhook',
'webhooks.list.empty.description': '创建第一个 Webhook 开始接收事件',
'webhooks.list.create': '创建 Webhook',
'webhooks.list.card.last-triggered': '最近触发',
'webhooks.list.card.never': '从未',
'webhooks.list.card.created': '创建时间',
'webhooks.create.title': '创建 Webhook',
'webhooks.create.description': '创建新的 Webhook 以接收事件',
'webhooks.create.success': 'Webhook 创建成功',
'webhooks.create.back': '返回',
'webhooks.create.form.submit': '创建 Webhook',
'webhooks.create.form.name.label': 'Webhook 名称',
'webhooks.create.form.name.placeholder': '请输入 Webhook 名称',
'webhooks.create.form.name.required': '名称为必填项',
'webhooks.create.form.url.label': 'Webhook URL',
'webhooks.create.form.url.placeholder': '请输入 Webhook URL',
'webhooks.create.form.url.required': 'URL 为必填项',
'webhooks.create.form.url.invalid': 'URL 无效',
'webhooks.create.form.secret.label': '密钥',
'webhooks.create.form.secret.placeholder': '请输入 Webhook 密钥',
'webhooks.create.form.events.label': '事件',
'webhooks.create.form.events.required': '至少选择一个事件',
'webhooks.update.title': '编辑 Webhook',
'webhooks.update.description': '更新 Webhook 信息',
'webhooks.update.success': 'Webhook 更新成功',
'webhooks.update.submit': '更新 Webhook',
'webhooks.update.cancel': '取消',
'webhooks.update.form.secret.placeholder': '输入新密钥',
'webhooks.update.form.secret.placeholder-redacted': '[已隐藏的密钥]',
'webhooks.update.form.rotate-secret.button': '轮换密钥',
'webhooks.delete.success': 'Webhook 已删除',
'webhooks.delete.confirm.title': '删除 Webhook',
'webhooks.delete.confirm.message': '确定要删除此 Webhook 吗?',
'webhooks.delete.confirm.confirm-button': '删除',
'webhooks.delete.confirm.cancel-button': '取消',
'webhooks.events.documents.title': '文档事件',
'webhooks.events.documents.document:created.description': '文档已创建',
'webhooks.events.documents.document:deleted.description': '文档已删除',
'webhooks.events.documents.document:updated.description': '文档已更新',
'webhooks.events.documents.document:tag:added.description': '文档已添加标签',
'webhooks.events.documents.document:tag:removed.description': '文档已移除标签',
// Navigation
'layout.menu.home': '首页',
'layout.menu.documents': '文档',
'layout.menu.tags': '标签',
'layout.menu.tagging-rules': '标签规则',
'layout.menu.deleted-documents': '已删除文档',
'layout.menu.organization-settings': '设置',
'layout.menu.api-keys': 'API 密钥',
'layout.menu.settings': '设置',
'layout.menu.account': '账户',
'layout.menu.general-settings': '常规设置',
'layout.menu.usage': '使用情况',
'layout.menu.intake-emails': '接收邮箱',
'layout.menu.webhooks': 'Webhook',
'layout.menu.members': '成员',
'layout.menu.invitations': '邀请',
'layout.upgrade-cta.title': '需要更多空间?',
'layout.upgrade-cta.description': '获得 10 倍存储和团队协作功能',
'layout.upgrade-cta.button': '立即升级',
'layout.theme.light': '浅色模式',
'layout.theme.dark': '深色模式',
'layout.theme.system': '跟随系统',
'layout.search.placeholder': '搜索...',
'layout.menu.import-document': '导入文档',
'user-menu.account-settings': '账户设置',
'user-menu.api-keys': 'API 密钥',
'user-menu.invitations': '邀请',
'user-menu.language': '语言',
'user-menu.logout': '登出',
// Command palette
'command-palette.search.placeholder': '搜索命令或文档',
'command-palette.no-results': '未找到结果',
'command-palette.sections.documents': '文档',
'command-palette.sections.theme': '主题',
// API errors
'api-errors.document.already_exists': '文档已存在',
'api-errors.document.size_too_large': '文件大小过大',
'api-errors.intake-emails.already_exists': '具有此地址的接收邮箱已存在。',
'api-errors.intake_email.limit_reached': '该组织的接收邮箱数量已达到上限。请升级您的方案以创建更多接收邮箱。',
'api-errors.user.max_organization_count_reached': '您已达到可创建的组织数量上限,如需创建更多,请联系支持。',
'api-errors.default': '处理请求时发生错误。',
'api-errors.organization.invitation_already_exists': '此邮箱的邀请在该组织中已存在。',
'api-errors.user.already_in_organization': '该用户已在此组织中。',
'api-errors.user.organization_invitation_limit_reached': '今日邀请次数已达上限,请明天再试。',
'api-errors.demo.not_available': '此功能在演示环境中不可用',
'api-errors.tags.already_exists': '该组织已存在同名标签',
'api-errors.internal.error': '处理请求时发生错误。请稍后重试。',
'api-errors.auth.invalid_origin': '应用来源无效。如果您自托管 Papra请确保 APP_BASE_URL 环境变量与当前 URL 匹配。详情见 https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': '该组织的成员和待处理邀请数量已达上限。请升级方案以添加更多成员。',
'api-errors.organization.has_active_subscription': '无法删除有有效订阅的组织。请先通过上方“管理订阅”取消订阅。',
// Better auth api errors
'api-errors.USER_NOT_FOUND': '未找到用户',
'api-errors.FAILED_TO_CREATE_USER': '创建用户失败',
'api-errors.FAILED_TO_CREATE_SESSION': '创建会话失败',
'api-errors.FAILED_TO_UPDATE_USER': '更新用户失败',
'api-errors.FAILED_TO_GET_SESSION': '获取会话失败',
'api-errors.INVALID_PASSWORD': '密码无效',
'api-errors.INVALID_EMAIL': '电子邮件无效',
'api-errors.INVALID_EMAIL_OR_PASSWORD': '邮箱或密码不正确,或账户不存在。',
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': '社交账户已关联',
'api-errors.PROVIDER_NOT_FOUND': '未找到提供者',
'api-errors.INVALID_TOKEN': '令牌无效',
'api-errors.ID_TOKEN_NOT_SUPPORTED': '不支持 ID 令牌',
'api-errors.FAILED_TO_GET_USER_INFO': '获取用户信息失败',
'api-errors.USER_EMAIL_NOT_FOUND': '未找到用户邮箱',
'api-errors.EMAIL_NOT_VERIFIED': '邮箱未验证',
'api-errors.PASSWORD_TOO_SHORT': '密码太短',
'api-errors.PASSWORD_TOO_LONG': '密码太长',
'api-errors.USER_ALREADY_EXISTS': '该邮箱的用户已存在',
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': '邮箱无法更新',
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': '未找到凭证账户',
'api-errors.SESSION_EXPIRED': '会话已过期',
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': '无法解除最后一个账户的关联',
'api-errors.ACCOUNT_NOT_FOUND': '账户未找到',
'api-errors.USER_ALREADY_HAS_PASSWORD': '用户已设置密码',
// Not found
'not-found.title': '404 - 未找到',
'not-found.description': '抱歉,您访问的页面不存在。请检查 URL 并重试。',
'not-found.back-to-home': '返回首页',
// Demo
'demo.popup.description': '这是一个演示环境,所有数据保存在浏览器本地存储。',
'demo.popup.discord': '加入 {{ discordLink }} 获取支持、建议功能或聊天。',
'demo.popup.discord-link-label': 'Discord 服务器',
'demo.popup.reset': '重置演示数据',
'demo.popup.hide': '隐藏',
// Color picker
'color-picker.hue': '色相',
'color-picker.saturation': '饱和度',
'color-picker.lightness': '亮度',
'color-picker.select-color': '选择颜色',
'color-picker.select-a-color': '选择一个颜色',
// Subscriptions
'subscriptions.checkout-success.title': '支付成功!',
'subscriptions.checkout-success.description': '您的订阅已成功激活。',
'subscriptions.checkout-success.thank-you': '感谢升级到 Papra Plus。您现在可以使用所有高级功能。',
'subscriptions.checkout-success.go-to-organizations': '前往组织',
'subscriptions.checkout-success.redirecting': '将在 {{ count }} 秒后跳转...',
'subscriptions.checkout-cancel.title': '支付已取消',
'subscriptions.checkout-cancel.description': '订阅升级已取消。',
'subscriptions.checkout-cancel.no-charges': '您的账户未被扣款。您可以随时重试。',
'subscriptions.checkout-cancel.back-to-organizations': '返回组织',
'subscriptions.checkout-cancel.need-help': '需要帮助?',
'subscriptions.checkout-cancel.contact-support': '联系客服',
'subscriptions.upgrade-dialog.title': '升级此组织',
'subscriptions.upgrade-dialog.description': '为组织解锁强大功能',
'subscriptions.upgrade-dialog.contact-us': '联系我们',
'subscriptions.upgrade-dialog.enterprise-plans': '如需定制企业方案请联系。',
'subscriptions.upgrade-dialog.current-plan': '当前方案',
'subscriptions.upgrade-dialog.recommended': '推荐',
'subscriptions.upgrade-dialog.per-month': '/月',
'subscriptions.upgrade-dialog.billed-annually': '按年计费:${{ price }}',
'subscriptions.upgrade-dialog.upgrade-now': '立即升级',
'subscriptions.upgrade-dialog.promo-banner.title': '限时优惠',
'subscriptions.upgrade-dialog.promo-banner.description': '作为早期采用者,组织可永久获得所有方案 {{ percent }}% 折扣!优惠于 {{ days, >1:{days} 天, =1:1 天, 少于 1 天 }} 到期。',
'subscriptions.plan.free.name': '免费方案',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.plan.pro.name': 'Pro',
'subscriptions.features.storage-size': '文档存储空间',
'subscriptions.features.members': '组织成员',
'subscriptions.features.members-count': '{{ count }} 名成员',
'subscriptions.features.email-intakes': '接收邮箱',
'subscriptions.features.email-intakes-count-singular': '{{ count }} 个地址',
'subscriptions.features.email-intakes-count-plural': '{{ count }} 个地址',
'subscriptions.features.max-upload-size': '最大上传文件大小',
'subscriptions.features.support': '支持',
'subscriptions.features.support-community': '社区支持',
'subscriptions.features.support-email': '邮件支持',
'subscriptions.features.support-priority': '优先支持',
'subscriptions.billing-interval.monthly': '按月',
'subscriptions.billing-interval.annual': '按年',
'subscriptions.usage-warning.message': '您的文档存储已使用 {{ percent }}%,考虑升级方案以获得更多空间。',
'subscriptions.usage-warning.upgrade-button': '升级方案',
// Common / Shared
'common.confirm-modal.type-to-confirm': '输入 "{{ text }}" 以确认',
'common.tables.rows-per-page': '每页行数',
'common.tables.pagination-info': '第 {{ currentPage }} 页,共 {{ totalPages }} 页',
};

View File

@@ -0,0 +1,39 @@
import type { RouteDefinition } from '@solidjs/router';
import { Navigate } from '@solidjs/router';
import { lazy } from 'solid-js';
import { NotFoundPage } from '../shared/pages/not-found.page';
export const adminRoutes: RouteDefinition = {
path: '/admin/*',
component: lazy(() => import('./layouts/admin.layout')),
children: [
{
path: '/',
component: () => <Navigate href="/admin/analytics" />,
},
{
path: '/users',
component: lazy(() => import('./users/pages/list-users.page')),
},
{
path: '/users/:userId',
component: lazy(() => import('./users/pages/user-detail.page')),
},
{
path: '/analytics',
component: lazy(() => import('./analytics/pages/analytics.page')),
},
{
path: '/organizations',
component: lazy(() => import('./organizations/pages/list-organizations.page')),
},
{
path: '/organizations/:organizationId',
component: lazy(() => import('./organizations/pages/organization-detail.page')),
},
{
path: '/*404',
component: NotFoundPage,
},
],
};

View File

@@ -0,0 +1,35 @@
import { apiClient } from '@/modules/shared/http/api-client';
export async function getUserCount() {
const { userCount } = await apiClient<{ userCount: number }>({
method: 'GET',
path: '/api/admin/users/count',
});
return { userCount };
}
export async function getDocumentStats() {
const stats = await apiClient<{
documentsCount: number;
documentsSize: number;
deletedDocumentsCount: number;
deletedDocumentsSize: number;
totalDocumentsCount: number;
totalDocumentsSize: number;
}>({
method: 'GET',
path: '/api/admin/documents/stats',
});
return stats;
}
export async function getOrganizationCount() {
const { organizationCount } = await apiClient<{ organizationCount: number }>({
method: 'GET',
path: '/api/admin/organizations/count',
});
return { organizationCount };
}

View File

@@ -0,0 +1,103 @@
import type { Component } from 'solid-js';
import { formatBytes } from '@corentinth/chisels';
import { useQuery } from '@tanstack/solid-query';
import { Suspense } from 'solid-js';
import { getDocumentStats, getOrganizationCount, getUserCount } from '../analytics.services';
const AnalyticsCard: Component<{
icon: string;
title: string;
value: () => number | undefined;
formatValue?: (value: number) => string;
}> = (props) => {
const formattedValue = () => {
const value = props.value();
if (value === undefined) {
return '';
}
return props.formatValue ? props.formatValue(value) : value.toLocaleString();
};
return (
<div class="bg-card rounded-lg px-6 py-4 border">
<div class="flex flex-row items-center mb-4 gap-2">
<div class="flex items-center justify-center size-6 bg-muted rounded">
<div class={`${props.icon} text-muted-foreground size-4`} />
</div>
<h2 class="text-sm font-light">{props.title}</h2>
</div>
<Suspense fallback={<div class="h-8 w-16 animate-pulse bg-muted rounded" />}>
<div class="text-3xl font-light">
{formattedValue()}
</div>
</Suspense>
</div>
);
};
export const AdminAnalyticsPage: Component = () => {
const userCountQuery = useQuery(() => ({
queryKey: ['admin', 'users', 'count'],
queryFn: getUserCount,
}));
const documentStatsQuery = useQuery(() => ({
queryKey: ['admin', 'documents', 'stats'],
queryFn: getDocumentStats,
}));
const organizationCountQuery = useQuery(() => ({
queryKey: ['admin', 'organizations', 'count'],
queryFn: getOrganizationCount,
}));
return (
<div class="px-6 pt-4">
<h1 class="text-2xl font-medium mb-1">Dashboard</h1>
<p class="text-muted-foreground">Insights and analytics about Papra usage.</p>
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<AnalyticsCard
icon="i-tabler-users"
title="User count"
value={() => userCountQuery.data?.userCount}
/>
<AnalyticsCard
icon="i-tabler-building"
title="Organization count"
value={() => organizationCountQuery.data?.organizationCount}
/>
<AnalyticsCard
icon="i-tabler-file"
title="Document count"
value={() => documentStatsQuery.data?.documentsCount}
/>
<AnalyticsCard
icon="i-tabler-database"
title="Documents storage"
value={() => documentStatsQuery.data?.documentsSize}
formatValue={bytes => formatBytes({ bytes, base: 1000 })}
/>
<AnalyticsCard
icon="i-tabler-file-x"
title="Deleted documents"
value={() => documentStatsQuery.data?.deletedDocumentsCount}
/>
<AnalyticsCard
icon="i-tabler-database-x"
title="Deleted storage"
value={() => documentStatsQuery.data?.deletedDocumentsSize}
formatValue={bytes => formatBytes({ bytes, base: 1000 })}
/>
</div>
</div>
);
};
export default AdminAnalyticsPage;

View File

@@ -0,0 +1,97 @@
import type { ParentComponent } from 'solid-js';
import { A, Navigate } from '@solidjs/router';
import { Show } from 'solid-js';
import { Button } from '@/modules/ui/components/button';
import { Sheet, SheetContent, SheetTrigger } from '@/modules/ui/components/sheet';
import { SideNav } from '@/modules/ui/layouts/sidenav.layout';
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
const AdminLayout: ParentComponent = (props) => {
const getNavigationMenu = () => [
{
label: 'Analytics',
href: '/admin/analytics',
icon: 'i-tabler-chart-bar',
},
{
label: 'Users',
href: '/admin/users',
icon: 'i-tabler-users',
},
{
label: 'Organizations',
href: '/admin/organizations',
icon: 'i-tabler-building-community',
},
];
const sidenav = () => (
<SideNav
header={() => (
<A href="/admin" class="flex items-center gap-2 pl-6 h-14 w-260px">
<div class="i-tabler-layout-dashboard text-primary size-7" />
<div class="font-medium text-base">
Papra admin
</div>
</A>
)}
mainMenu={getNavigationMenu()}
footer={() => (
<div class="px-4 text-sm text-muted-foreground text-center">
Papra &copy;
{' '}
{new Date().getFullYear()}
</div>
)}
/>
);
return (
<div class="h-screen bg-card flex flex-row flex-1 min-h-0">
<div class="w-280px flex-shrink-0 hidden md:block">
{sidenav()}
</div>
<div class="flex-1 flex flex-col min-h-0">
<header class="h-14 flex items-center px-4 justify-between">
<Sheet>
<SheetTrigger>
<Button variant="ghost" size="icon" class="md:hidden mr-2">
<div class="i-tabler-menu-2 size-6" />
</Button>
</SheetTrigger>
<SheetContent side="left" class="bg-card p-0!">
{sidenav()}
</SheetContent>
</Sheet>
<Button
variant="outline"
as={A}
href="/"
>
Back to App
</Button>
</header>
<div class="flex-1 min-h-0 flex flex-col md:rounded-tl-lg md:border-l border-t bg-background">
{props.children}
</div>
</div>
</div>
);
};
export const GuardedAdminLayout: ParentComponent = (props) => {
const { hasPermission } = useCurrentUser();
return (
<Show
when={hasPermission('bo:access')}
fallback={<Navigate href="/" />}
>
<AdminLayout {...props} />
</Show>
);
};
export default GuardedAdminLayout;

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

@@ -0,0 +1,232 @@
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 { 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 = () => {
const [search, setSearch] = createSignal('');
const [pagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 25 });
const query = useQuery(() => ({
queryKey: ['admin', 'users', search(), pagination()],
queryFn: () => listUsers({
search: search() || undefined,
...pagination(),
}),
}));
const table = createSolidTable({
get data() {
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-muted-foreground"
>
{data.getValue<string>()}
</A>
),
},
{
header: 'Status',
accessorKey: 'emailVerified',
cell: data => (
<Badge variant={data.getValue<boolean>() ? 'default' : 'outline'}>
{data.getValue<boolean>() ? 'Verified' : 'Unverified'}
</Badge>
),
},
{
header: 'Orgs',
accessorKey: 'organizationCount',
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>())} />,
},
],
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">
User Management
</h1>
<p class="text-sm text-muted-foreground">
Manage and view all users in the system
</p>
</div>
<div class="mb-4">
<TextFieldRoot class="max-w-sm">
<TextField
type="text"
placeholder="Search by name, email, or ID..."
value={search()}
onInput={handleSearch}
/>
</TextFieldRoot>
</div>
<Show
when={!query.isLoading}
fallback={<div class="text-center py-8 text-muted-foreground">Loading users...</div>}
>
<Show
when={(query.data?.users.length ?? 0) > 0}
fallback={(
<div class="text-center py-8 text-muted-foreground">
{search() ? 'No users found matching your search.' : 'No users 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}
{' '}
users
</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 AdminListUsersPage;

View File

@@ -0,0 +1,168 @@
import type { Component } from 'solid-js';
import { A, useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { For, Show } 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 { getUserDetail } from '../users.services';
export const AdminUserDetailPage: Component = () => {
const params = useParams<{ userId: string }>();
const query = useQuery(() => ({
queryKey: ['admin', 'users', params.userId],
queryFn: () => getUserDetail({ userId: params.userId }),
}));
return (
<div class="p-6 max-w-screen-lg mx-auto mt-4">
<div class="mb-6">
<Button as={A} href="/admin/users" variant="ghost" size="sm" class="mb-4">
<div class="i-tabler-arrow-left size-4 mr-2" />
Back to Users
</Button>
<Show
when={!query.isLoading && query.data}
fallback={<div class="text-center py-8 text-muted-foreground">Loading user details...</div>}
>
{data => (
<div class="space-y-6">
<div class="border-b pb-4">
<h1 class="text-2xl font-bold flex items-center gap-3">
{data().user.name || 'Unnamed User'}
</h1>
<p class="text-muted-foreground mt-1">{data().user.email}</p>
</div>
<div class="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>User Information</CardTitle>
<CardDescription>Basic user details and account information</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<div class="flex justify-between items-start">
<span class="text-sm text-muted-foreground">User ID</span>
<span class="font-mono text-xs">{data().user.id}</span>
</div>
<div class="flex justify-between items-start">
<span class="text-sm text-muted-foreground">Email</span>
<span class="text-sm font-medium">{data().user.email}</span>
</div>
<div class="flex justify-between items-start">
<span class="text-sm text-muted-foreground">Name</span>
<span class="text-sm">{data().user.name || '-'}</span>
</div>
<div class="flex justify-between items-start">
<span class="text-sm text-muted-foreground">Email Verified</span>
<Badge variant={data().user.emailVerified ? 'default' : 'outline'} class="text-xs">
{data().user.emailVerified ? 'Yes' : 'No'}
</Badge>
</div>
<div class="flex justify-between items-start">
<span class="text-sm text-muted-foreground">Max Organizations</span>
<span class="text-sm">{data().user.maxOrganizationCount ?? 'Unlimited'}</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().user.createdAt)} />
</div>
<div class="flex justify-between items-start">
<span class="text-sm text-muted-foreground">Last Updated</span>
<RelativeTime class="text-sm" date={new Date(data().user.updatedAt)} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Roles & Permissions</CardTitle>
<CardDescription>User roles and access levels</CardDescription>
</CardHeader>
<CardContent>
<Show
when={data().roles.length > 0}
fallback={<p class="text-sm text-muted-foreground">No roles assigned</p>}
>
<div class="flex flex-wrap gap-2">
<For each={data().roles}>
{role => (
<Badge variant="secondary" class="font-mono">
{role}
</Badge>
)}
</For>
</div>
</Show>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>
Organizations (
{data().organizations.length}
)
</CardTitle>
<CardDescription>Organizations this user belongs to</CardDescription>
</CardHeader>
<CardContent>
<Show
when={data().organizations.length > 0}
fallback={<p class="text-sm text-muted-foreground">Not a member of any organizations</p>}
>
<div class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={data().organizations}>
{org => (
<TableRow>
<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>
</TableRow>
)}
</For>
</TableBody>
</Table>
</div>
</Show>
</CardContent>
</Card>
</div>
)}
</Show>
</div>
</div>
);
};
export default AdminUserDetailPage;

View File

@@ -0,0 +1,33 @@
import type { Organization } from '@/modules/organizations/organizations.types';
import type { User } from '@/modules/users/users.types';
import { apiClient } from '@/modules/shared/http/api-client';
export type UserWithOrganizationCount = User & { organizationCount: number };
export async function listUsers({ search, pageIndex = 0, pageSize = 25 }: { search?: string; pageIndex?: number; pageSize?: number }) {
const { totalCount, users } = await apiClient<{
users: UserWithOrganizationCount[];
totalCount: number;
pageIndex: number;
pageSize: number;
}>({
method: 'GET',
path: '/api/admin/users',
query: { search, pageIndex, pageSize },
});
return { pageIndex, pageSize, totalCount, users };
}
export async function getUserDetail({ userId }: { userId: string }) {
const { organizations, roles, user } = await apiClient<{
user: User;
organizations: Organization[];
roles: string[];
}>({
method: 'GET',
path: `/api/admin/users/${userId}`,
});
return { organizations, roles, user };
}

View File

@@ -17,7 +17,7 @@ export function createDemoAuthClient() {
}, },
signOut: () => Promise.resolve({}), signOut: () => Promise.resolve({}),
signUp: () => Promise.resolve({}), signUp: () => Promise.resolve({}),
forgetPassword: () => Promise.resolve({}), requestPasswordReset: () => Promise.resolve({}),
resetPassword: () => Promise.resolve({}), resetPassword: () => Promise.resolve({}),
sendVerificationEmail: () => Promise.resolve({}), sendVerificationEmail: () => Promise.resolve({}),
}; };

View File

@@ -20,7 +20,7 @@ export function createAuthClient() {
// we can't spread the client because it is a proxy object // we can't spread the client because it is a proxy object
signIn: client.signIn, signIn: client.signIn,
signUp: client.signUp, signUp: client.signUp,
forgetPassword: client.forgetPassword, requestPasswordReset: client.requestPasswordReset,
resetPassword: client.resetPassword, resetPassword: client.resetPassword,
sendVerificationEmail: client.sendVerificationEmail, sendVerificationEmail: client.sendVerificationEmail,
useSession: client.useSession, useSession: client.useSession,
@@ -41,7 +41,7 @@ export const {
signIn, signIn,
signUp, signUp,
signOut, signOut,
forgetPassword, requestPasswordReset,
resetPassword, resetPassword,
sendVerificationEmail, sendVerificationEmail,
} = buildTimeConfig.isDemoMode } = buildTimeConfig.isDemoMode

View File

@@ -106,7 +106,7 @@ export const EmailLoginForm: Component = () => {
</Show> </Show>
</div> </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> <div class="text-red-500 text-sm mt-4">{form.response.message}</div>

View File

@@ -100,7 +100,7 @@ export const EmailRegisterForm: Component = () => {
)} )}
</Field> </Field>
<Button type="submit" class="w-full">{t('auth.register.form.submit')}</Button> <Button type="submit" class="w-full" isLoading={form.submitting}>{t('auth.register.form.submit')}</Button>
<div class="text-red-500 text-sm mt-4">{form.response.message}</div> <div class="text-red-500 text-sm mt-4">{form.response.message}</div>

View File

@@ -9,7 +9,7 @@ import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button'; import { Button } from '@/modules/ui/components/button';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield'; import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { AuthLayout } from '../../ui/layouts/auth-layout.component'; import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { forgetPassword } from '../auth.services'; import { requestPasswordReset } from '../auth.services';
import { OpenEmailProvider } from '../components/open-email-provider.component'; import { OpenEmailProvider } from '../components/open-email-provider.component';
export const ResetPasswordForm: Component<{ onSubmit: (args: { email: string }) => Promise<void> }> = (props) => { export const ResetPasswordForm: Component<{ onSubmit: (args: { email: string }) => Promise<void> }> = (props) => {
@@ -64,7 +64,7 @@ export const RequestPasswordResetPage: Component = () => {
}); });
const onPasswordResetRequested = async ({ email }: { email: string }) => { const onPasswordResetRequested = async ({ email }: { email: string }) => {
const { error } = await forgetPassword({ const { error } = await requestPasswordReset({
email, email,
redirectTo: buildUrl({ redirectTo: buildUrl({
path: '/reset-password', path: '/reset-password',

View File

@@ -37,7 +37,7 @@ export const ResetPasswordForm: Component<{ onSubmit: (args: { newPassword: stri
)} )}
</Field> </Field>
<Button type="submit" class="w-full"> <Button type="submit" class="w-full" isLoading={form.submitting}>
{t('auth.reset-password.form.submit')} {t('auth.reset-password.form.submit')}
</Button> </Button>

View File

@@ -85,7 +85,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
id: 'usr_1', id: 'usr_1',
email: 'jane.doe@papra.app', email: 'jane.doe@papra.app',
name: 'Jane Doe', name: 'Jane Doe',
roles: [], permissions: [],
}, },
}), }),
}), }),

View File

@@ -2,13 +2,14 @@ import type { DropdownMenuSubTriggerProps } from '@kobalte/core/dropdown-menu';
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
import type { Document } from '../documents.types'; import type { Document } from '../documents.types';
import { A } from '@solidjs/router'; import { A } from '@solidjs/router';
import { Show } from 'solid-js';
import { Button } from '@/modules/ui/components/button'; import { Button } from '@/modules/ui/components/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
import { useDeleteDocument } from '../documents.composables'; import { useDeleteDocument } from '../documents.composables';
import { useRenameDocumentDialog } from './rename-document-button.component'; import { useRenameDocumentDialog } from './rename-document-button.component';
export const DocumentManagementDropdown: Component<{ document: Document }> = (props) => { export const DocumentManagementDropdown: Component<{ document: Document }> = (props) => {
const { deleteDocument } = useDeleteDocument(); const { deleteDocument, getIsDeletingDocument } = useDeleteDocument();
const { openRenameDialog } = useRenameDocumentDialog(); const { openRenameDialog } = useRenameDocumentDialog();
const deleteDoc = () => deleteDocument({ const deleteDoc = () => deleteDocument({
@@ -52,8 +53,14 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
<DropdownMenuItem <DropdownMenuItem
class="cursor-pointer text-red" class="cursor-pointer text-red"
onClick={() => deleteDoc()} onClick={() => deleteDoc()}
disabled={getIsDeletingDocument()}
> >
<Show when={getIsDeletingDocument()}>
<div class="i-tabler-loader-2 animate-spin size-4 mr-2" />
</Show>
<Show when={!getIsDeletingDocument()}>
<div class="i-tabler-trash size-4 mr-2" /> <div class="i-tabler-trash size-4 mr-2" />
</Show>
<span>Delete document</span> <span>Delete document</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -83,7 +83,7 @@ export const RenameDocumentDialog: Component<{
<Button type="button" variant="secondary" onClick={() => props.setIsOpen(false)}> <Button type="button" variant="secondary" onClick={() => props.setIsOpen(false)}>
{t('documents.rename.cancel')} {t('documents.rename.cancel')}
</Button> </Button>
<Button type="submit">{t('documents.rename.form.submit')}</Button> <Button type="submit" isLoading={renameDocumentMutation.isPending}>{t('documents.rename.form.submit')}</Button>
</div> </div>
</Form> </Form>
</DialogContent> </DialogContent>

View File

@@ -1,5 +1,6 @@
import type { DocumentActivityEvent } from './documents.types'; import type { DocumentActivityEvent } from './documents.types';
import { IN_MS } from '../shared/utils/units'; import { IN_MS } from '../shared/utils/units';
import { DEFAULT_DOCUMENT_ICON } from './documents.constants';
export const fileIcons: { mimeTypes: string[]; extensions: string[]; icon: string }[] = [ export const fileIcons: { mimeTypes: string[]; extensions: string[]; icon: string }[] = [
{ {
@@ -88,7 +89,7 @@ export function getDocumentIcon({
document, document,
iconByMimeTypeMap = iconByFileType, iconByMimeTypeMap = iconByFileType,
iconByExtensionMap = iconByExtension, iconByExtensionMap = iconByExtension,
defaultIcon = 'i-tabler-file', defaultIcon = DEFAULT_DOCUMENT_ICON,
}: { document: { }: { document: {
mimeType?: string; mimeType?: string;
name?: string; name?: string;

View File

@@ -24,8 +24,10 @@ function getConfirmMessage(documentName: string) {
export function useDeleteDocument() { export function useDeleteDocument() {
const { confirm } = useConfirmModal(); const { confirm } = useConfirmModal();
const [getIsDeletingDocument, setIsDeletingDocument] = createSignal(false);
return { return {
getIsDeletingDocument,
async deleteDocument({ documentId, organizationId, documentName }: { documentId: string; organizationId: string; documentName: string }): Promise<{ hasDeleted: boolean }> { async deleteDocument({ documentId, organizationId, documentName }: { documentId: string; organizationId: string; documentName: string }): Promise<{ hasDeleted: boolean }> {
const isConfirmed = await confirm({ const isConfirmed = await confirm({
title: 'Delete document', title: 'Delete document',
@@ -43,6 +45,8 @@ export function useDeleteDocument() {
return { hasDeleted: false }; return { hasDeleted: false };
} }
setIsDeletingDocument(true);
await deleteDocument({ await deleteDocument({
documentId, documentId,
organizationId, organizationId,
@@ -51,6 +55,8 @@ export function useDeleteDocument() {
await invalidateOrganizationDocumentsQuery({ organizationId }); await invalidateOrganizationDocumentsQuery({ organizationId });
createToast({ type: 'success', message: 'Document deleted' }); createToast({ type: 'success', message: 'Document deleted' });
setIsDeletingDocument(false);
return { hasDeleted: true }; return { hasDeleted: true };
}, },
}; };

View File

@@ -10,3 +10,5 @@ export const DOCUMENT_ACTIVITY_EVENTS = {
export const DOCUMENT_ACTIVITY_EVENT_LIST = Object.values(DOCUMENT_ACTIVITY_EVENTS); export const DOCUMENT_ACTIVITY_EVENT_LIST = Object.values(DOCUMENT_ACTIVITY_EVENTS);
export const MAX_CONCURRENT_DOCUMENT_UPLOADS = 3; export const MAX_CONCURRENT_DOCUMENT_UPLOADS = 3;
export const DEFAULT_DOCUMENT_ICON = 'i-tabler-file';

View File

@@ -9,4 +9,5 @@ export const locales = [
{ key: 'es', name: 'Español' }, { key: 'es', name: 'Español' },
{ key: 'it', name: 'Italiano' }, { key: 'it', name: 'Italiano' },
{ key: 'nl', name: 'Nederlands' }, { key: 'nl', name: 'Nederlands' },
{ key: 'zh', name: '简体中文' },
] as const; ] as const;

View File

@@ -30,6 +30,7 @@ const AllowedOriginsDialog: Component<{
onOpenChange?: (isOpen: boolean) => void; onOpenChange?: (isOpen: boolean) => void;
}> = (props) => { }> = (props) => {
const [getAllowedOrigins, setAllowedOrigins] = createSignal(props.intakeEmails?.allowedOrigins || []); const [getAllowedOrigins, setAllowedOrigins] = createSignal(props.intakeEmails?.allowedOrigins || []);
const [deletingOrigin, setDeletingOrigin] = createSignal<string | null>(null);
const { t } = useI18n(); const { t } = useI18n();
const update = async () => { const update = async () => {
@@ -45,8 +46,10 @@ const AllowedOriginsDialog: Component<{
}; };
const deleteAllowedOrigin = async ({ origin }: { origin: string }) => { const deleteAllowedOrigin = async ({ origin }: { origin: string }) => {
setDeletingOrigin(origin);
setAllowedOrigins(origins => origins.filter(o => o !== origin)); setAllowedOrigins(origins => origins.filter(o => o !== origin));
await update(); await update();
setDeletingOrigin(null);
}; };
const { form, Form, Field } = createForm({ const { form, Form, Field } = createForm({
@@ -109,7 +112,7 @@ const AllowedOriginsDialog: Component<{
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<TextField type="email" id="email" placeholder={t('intake-emails.allowed-origins.add.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} /> <TextField type="email" id="email" placeholder={t('intake-emails.allowed-origins.add.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
<Button type="submit"> <Button type="submit" isLoading={form.submitting}>
<div class="i-tabler-plus size-4 mr-2" /> <div class="i-tabler-plus size-4 mr-2" />
{t('intake-emails.allowed-origins.add.button')} {t('intake-emails.allowed-origins.add.button')}
</Button> </Button>
@@ -140,6 +143,7 @@ const AllowedOriginsDialog: Component<{
size="icon" size="icon"
class="text-red" class="text-red"
onClick={() => deleteAllowedOrigin({ origin })} onClick={() => deleteAllowedOrigin({ origin })}
isLoading={deletingOrigin() === origin}
> >
<div class="i-tabler-trash size-4" /> <div class="i-tabler-trash size-4" />
</Button> </Button>
@@ -157,6 +161,9 @@ export const IntakeEmailsPage: Component = () => {
const { t, te } = useI18n(); const { t, te } = useI18n();
const [selectedIntakeEmail, setSelectedIntakeEmail] = createSignal<IntakeEmail | null>(null); const [selectedIntakeEmail, setSelectedIntakeEmail] = createSignal<IntakeEmail | null>(null);
const [openDropdownId, setOpenDropdownId] = createSignal<string | null>(null); const [openDropdownId, setOpenDropdownId] = createSignal<string | null>(null);
const [isCreatingEmail, setIsCreatingEmail] = createSignal(false);
const [updatingEmailId, setUpdatingEmailId] = createSignal<string | null>(null);
const [deletingEmailId, setDeletingEmailId] = createSignal<string | null>(null);
if (!config.intakeEmails.isEnabled) { if (!config.intakeEmails.isEnabled) {
return ( return (
@@ -195,6 +202,8 @@ export const IntakeEmailsPage: Component = () => {
})); }));
const createEmail = async () => { const createEmail = async () => {
setIsCreatingEmail(true);
const [,error] = await safely(createIntakeEmail({ organizationId: params.organizationId })); const [,error] = await safely(createIntakeEmail({ organizationId: params.organizationId }));
if (error) { if (error) {
@@ -203,6 +212,7 @@ export const IntakeEmailsPage: Component = () => {
type: 'error', type: 'error',
}); });
setIsCreatingEmail(false);
throw error; throw error;
} }
@@ -212,6 +222,8 @@ export const IntakeEmailsPage: Component = () => {
message: t('intake-emails.create.success'), message: t('intake-emails.create.success'),
type: 'success', type: 'success',
}); });
setIsCreatingEmail(false);
}; };
const deleteEmail = async ({ intakeEmailId }: { intakeEmailId: string }) => { const deleteEmail = async ({ intakeEmailId }: { intakeEmailId: string }) => {
@@ -231,6 +243,8 @@ export const IntakeEmailsPage: Component = () => {
return; return;
} }
setDeletingEmailId(intakeEmailId);
await deleteIntakeEmail({ organizationId: params.organizationId, intakeEmailId }); await deleteIntakeEmail({ organizationId: params.organizationId, intakeEmailId });
await query.refetch(); await query.refetch();
@@ -238,9 +252,13 @@ export const IntakeEmailsPage: Component = () => {
message: t('intake-emails.delete.success'), message: t('intake-emails.delete.success'),
type: 'success', type: 'success',
}); });
setDeletingEmailId(null);
}; };
const updateEmail = async ({ intakeEmailId, isEnabled }: { intakeEmailId: string; isEnabled: boolean }) => { const updateEmail = async ({ intakeEmailId, isEnabled }: { intakeEmailId: string; isEnabled: boolean }) => {
setUpdatingEmailId(intakeEmailId);
await updateIntakeEmail({ organizationId: params.organizationId, intakeEmailId, isEnabled }); await updateIntakeEmail({ organizationId: params.organizationId, intakeEmailId, isEnabled });
await query.refetch(); await query.refetch();
@@ -248,6 +266,8 @@ export const IntakeEmailsPage: Component = () => {
message: isEnabled ? t('intake-emails.update.success.enabled') : t('intake-emails.update.success.disabled'), message: isEnabled ? t('intake-emails.update.success.enabled') : t('intake-emails.update.success.disabled'),
type: 'success', type: 'success',
}); });
setUpdatingEmailId(null);
}; };
const openAllowedOriginsDialog = (intakeEmail: IntakeEmail) => { const openAllowedOriginsDialog = (intakeEmail: IntakeEmail) => {
@@ -284,7 +304,7 @@ export const IntakeEmailsPage: Component = () => {
class="pt-0" class="pt-0"
icon="i-tabler-mail" icon="i-tabler-mail"
cta={( cta={(
<Button variant="secondary" onClick={createEmail}> <Button variant="secondary" onClick={createEmail} isLoading={isCreatingEmail()}>
<div class="i-tabler-plus size-4 mr-2" /> <div class="i-tabler-plus size-4 mr-2" />
{t('intake-emails.empty.generate')} {t('intake-emails.empty.generate')}
</Button> </Button>
@@ -301,7 +321,7 @@ export const IntakeEmailsPage: Component = () => {
})} })}
</div> </div>
<Button onClick={createEmail}> <Button onClick={createEmail} isLoading={isCreatingEmail()}>
<div class="i-tabler-plus size-4 mr-2" /> <div class="i-tabler-plus size-4 mr-2" />
{t('intake-emails.new')} {t('intake-emails.new')}
</Button> </Button>
@@ -359,8 +379,14 @@ export const IntakeEmailsPage: Component = () => {
setOpenDropdownId(null); setOpenDropdownId(null);
updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled }); updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled });
}} }}
disabled={updatingEmailId() === intakeEmail.id}
> >
<Show when={updatingEmailId() === intakeEmail.id}>
<div class="i-tabler-loader-2 animate-spin size-4 mr-2" />
</Show>
<Show when={updatingEmailId() !== intakeEmail.id}>
<div class="i-tabler-power size-4 mr-2" /> <div class="i-tabler-power size-4 mr-2" />
</Show>
{intakeEmail.isEnabled ? t('intake-emails.actions.disable') : t('intake-emails.actions.enable')} {intakeEmail.isEnabled ? t('intake-emails.actions.disable') : t('intake-emails.actions.enable')}
</DropdownMenuItem> </DropdownMenuItem>
@@ -377,8 +403,14 @@ export const IntakeEmailsPage: Component = () => {
deleteEmail({ intakeEmailId: intakeEmail.id }); deleteEmail({ intakeEmailId: intakeEmail.id });
}} }}
class="text-red" class="text-red"
disabled={deletingEmailId() === intakeEmail.id}
> >
<Show when={deletingEmailId() === intakeEmail.id}>
<div class="i-tabler-loader-2 animate-spin size-4 mr-2" />
</Show>
<Show when={deletingEmailId() !== intakeEmail.id}>
<div class="i-tabler-trash size-4 mr-2" /> <div class="i-tabler-trash size-4 mr-2" />
</Show>
{t('intake-emails.actions.delete')} {t('intake-emails.actions.delete')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -3,7 +3,7 @@ import type { OrganizationMemberRole } from '../organizations.types';
import { A, useParams } from '@solidjs/router'; import { A, useParams } from '@solidjs/router';
import { useMutation, useQuery } from '@tanstack/solid-query'; import { useMutation, useQuery } from '@tanstack/solid-query';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table'; import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { For, Show } from 'solid-js'; import { createSignal, For, Show } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider'; import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm'; import { useConfirmModal } from '@/modules/shared/confirm';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors'; import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
@@ -30,6 +30,9 @@ const MemberList: Component = () => {
const { getIsAtLeastAdmin, getRole } = useCurrentUserRole({ organizationId: params.organizationId }); const { getIsAtLeastAdmin, getRole } = useCurrentUserRole({ organizationId: params.organizationId });
const [deletingMemberId, setDeletingMemberId] = createSignal<string | null>(null);
const [updatingMemberId, setUpdatingMemberId] = createSignal<string | null>(null);
const removeMemberMutation = useMutation(() => ({ const removeMemberMutation = useMutation(() => ({
mutationFn: ({ memberId }: { memberId: string }) => removeOrganizationMember({ organizationId: params.organizationId, memberId }), mutationFn: ({ memberId }: { memberId: string }) => removeOrganizationMember({ organizationId: params.organizationId, memberId }),
onSuccess: () => { onSuccess: () => {
@@ -75,11 +78,23 @@ const MemberList: Component = () => {
return; return;
} }
removeMemberMutation.mutate({ memberId }); setDeletingMemberId(memberId);
try {
await removeMemberMutation.mutateAsync({ memberId });
}
finally {
setDeletingMemberId(null);
}
}; };
const handleUpdateMemberRole = async ({ memberId, role }: { memberId: string; role: OrganizationMemberRole }) => { const handleUpdateMemberRole = async ({ memberId, role }: { memberId: string; role: OrganizationMemberRole }) => {
setUpdatingMemberId(memberId);
try {
await updateMemberRoleMutation.mutateAsync({ memberId, role }); await updateMemberRoleMutation.mutateAsync({ memberId, role });
}
finally {
setUpdatingMemberId(null);
}
}; };
const table = createSolidTable({ const table = createSolidTable({
@@ -99,9 +114,14 @@ const MemberList: Component = () => {
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleDelete({ memberId: data.row.original.id })} onClick={() => handleDelete({ memberId: data.row.original.id })}
disabled={data.row.original.role === ORGANIZATION_ROLES.OWNER || !getIsAtLeastAdmin()} disabled={data.row.original.role === ORGANIZATION_ROLES.OWNER || !getIsAtLeastAdmin() || deletingMemberId() === data.row.original.id}
> >
<Show when={deletingMemberId() === data.row.original.id}>
<div class="i-tabler-loader-2 animate-spin size-4 mr-2" />
</Show>
<Show when={deletingMemberId() !== data.row.original.id}>
<div class="i-tabler-user-x size-4 mr-2" /> <div class="i-tabler-user-x size-4 mr-2" />
</Show>
{t('organizations.members.remove-from-organization')} {t('organizations.members.remove-from-organization')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -111,19 +131,19 @@ const MemberList: Component = () => {
<DropdownMenuRadioGroup value={data.row.original.role} onChange={role => handleUpdateMemberRole({ memberId: data.row.original.id, role: role as OrganizationMemberRole })}> <DropdownMenuRadioGroup value={data.row.original.role} onChange={role => handleUpdateMemberRole({ memberId: data.row.original.id, role: role as OrganizationMemberRole })}>
<DropdownMenuRadioItem <DropdownMenuRadioItem
value={ORGANIZATION_ROLES.OWNER} value={ORGANIZATION_ROLES.OWNER}
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.OWNER })} disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.OWNER }) || updatingMemberId() === data.row.original.id}
> >
{t(`organizations.members.roles.owner`)} {t(`organizations.members.roles.owner`)}
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
<DropdownMenuRadioItem <DropdownMenuRadioItem
value={ORGANIZATION_ROLES.ADMIN} value={ORGANIZATION_ROLES.ADMIN}
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.ADMIN })} disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.ADMIN }) || updatingMemberId() === data.row.original.id}
> >
{t(`organizations.members.roles.admin`)} {t(`organizations.members.roles.admin`)}
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
<DropdownMenuRadioItem <DropdownMenuRadioItem
value={ORGANIZATION_ROLES.MEMBER} value={ORGANIZATION_ROLES.MEMBER}
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.MEMBER })} disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.MEMBER }) || updatingMemberId() === data.row.original.id}
> >
{t(`organizations.members.roles.member`)} {t(`organizations.members.roles.member`)}
</DropdownMenuRadioItem> </DropdownMenuRadioItem>

View File

@@ -12,5 +12,11 @@ function baseHttpClient<A, R extends ResponseType = 'json'>({ url, baseUrl, ...r
}); });
} }
// eslint-disable-next-line antfu/no-top-level-await export async function httpClient<A, R extends ResponseType = 'json'>(options: HttpClientOptions<R>) {
export const httpClient = buildTimeConfig.isDemoMode ? await import('@/modules/demo/demo-http-client').then(m => m.demoHttpClient) : baseHttpClient; if (buildTimeConfig.isDemoMode) {
const { demoHttpClient } = await import('@/modules/demo/demo-http-client');
return demoHttpClient<A, R>(options);
}
return baseHttpClient<A, R>(options);
}

View File

@@ -1,10 +1,20 @@
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
import { A } from '@solidjs/router'; import { A, useLocation } from '@solidjs/router';
import { useI18n } from '@/modules/i18n/i18n.provider'; import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button'; import { Button } from '@/modules/ui/components/button';
export const NotFoundPage: Component = () => { export const NotFoundPage: Component = () => {
const { t } = useI18n(); const { t } = useI18n();
const location = useLocation();
const getRedirectionUrl = () => {
if (location.pathname.startsWith('/admin/') || location.pathname === '/admin') {
return '/admin';
}
return '/';
};
return ( return (
<div class="h-screen flex flex-col items-center justify-center p-6"> <div class="h-screen flex flex-col items-center justify-center p-6">
@@ -14,7 +24,7 @@ export const NotFoundPage: Component = () => {
<p class="text-muted-foreground"> <p class="text-muted-foreground">
{t('not-found.description')} {t('not-found.description')}
</p> </p>
<Button as={A} href="/" class="mt-4" variant="default"> <Button as={A} href={getRedirectionUrl()} class="mt-4" variant="default">
<div class="i-tabler-arrow-left mr-2" /> <div class="i-tabler-arrow-left mr-2" />
{t('not-found.back-to-home')} {t('not-found.back-to-home')}
</Button> </Button>

View File

@@ -284,7 +284,7 @@ export const TaggingRuleForm: Component<{
</Button> </Button>
</Show> </Show>
<Button type="submit">{props.submitButtonText ?? t('tagging-rules.form.submit')}</Button> <Button type="submit" isLoading={form.submitting}>{props.submitButtonText ?? t('tagging-rules.form.submit')}</Button>
</div> </div>
</Form> </Form>
); );

View File

@@ -115,7 +115,7 @@ const TagForm: Component<{
</Field> </Field>
<div class="flex flex-row-reverse justify-between items-center mt-6"> <div class="flex flex-row-reverse justify-between items-center mt-6">
<Button type="submit"> <Button type="submit" isLoading={form.submitting}>
{props.submitLabel ?? t('tags.create')} {props.submitLabel ?? t('tags.create')}
</Button> </Button>
@@ -229,6 +229,7 @@ export const TagsPage: Component = () => {
const { confirm } = useConfirmModal(); const { confirm } = useConfirmModal();
const { t } = useI18n(); const { t } = useI18n();
const { getErrorMessage } = useI18nApiErrors({ t }); const { getErrorMessage } = useI18nApiErrors({ t });
const [deletingTagId, setDeletingTagId] = createSignal<string | null>(null);
const query = useQuery(() => ({ const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'tags'], queryKey: ['organizations', params.organizationId, 'tags'],
@@ -253,6 +254,8 @@ export const TagsPage: Component = () => {
return; return;
} }
setDeletingTagId(tag.id);
const [, error] = await safely(deleteTag({ const [, error] = await safely(deleteTag({
organizationId: params.organizationId, organizationId: params.organizationId,
tagId: tag.id, tagId: tag.id,
@@ -264,6 +267,7 @@ export const TagsPage: Component = () => {
type: 'error', type: 'error',
}); });
setDeletingTagId(null);
return; return;
} }
@@ -276,6 +280,8 @@ export const TagsPage: Component = () => {
message: t('tags.delete.success'), message: t('tags.delete.success'),
type: 'success', type: 'success',
}); });
setDeletingTagId(null);
}; };
return ( return (
@@ -368,7 +374,7 @@ export const TagsPage: Component = () => {
)} )}
</UpdateTagModal> </UpdateTagModal>
<Button size="icon" variant="outline" class="size-7 text-red" onClick={() => del({ tag })}> <Button size="icon" variant="outline" class="size-7 text-red" onClick={() => del({ tag })} isLoading={deletingTagId() === tag.id}>
<div class="i-tabler-trash size-4" /> <div class="i-tabler-trash size-4" />
</Button> </Button>
</div> </div>

View File

@@ -0,0 +1,24 @@
import type { Component } from 'solid-js';
import { For } from 'solid-js';
import { Skeleton } from '../skeleton';
type cardSkeletonProps = {
lines?: number;
};
export const CardSkeleton: Component<cardSkeletonProps> = (props) => {
const lines = () => props.lines ?? 3;
return (
<div class="border border-border rounded-lg p-4">
<Skeleton class="h-6 w-1/3 mb-3" />
<div class="space-y-2">
<For each={Array.from({ length: lines() })}>
{(_, index) => (
<Skeleton class={`h-4 ${index() === lines() - 1 ? 'w-2/3' : 'w-full'}`} />
)}
</For>
</div>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import type { Component } from 'solid-js';
import { For } from 'solid-js';
import { CardSkeleton } from './card-skeleton';
type gridSkeletonProps = {
items?: number;
columns?: number;
};
export const GridSkeleton: Component<gridSkeletonProps> = (props) => {
const items = () => props.items ?? 6;
const columns = () => props.columns ?? 3;
return (
<div
class="grid gap-4"
style={{
'grid-template-columns': `repeat(${columns()}, minmax(0, 1fr))`,
}}
>
<For each={Array.from({ length: items() })}>
{() => <CardSkeleton />}
</For>
</div>
);
};

View File

@@ -0,0 +1,3 @@
export { TableSkeleton } from './table-skeleton';
export { CardSkeleton } from './card-skeleton';
export { GridSkeleton } from './grid-skeleton';

View File

@@ -0,0 +1,31 @@
import type { Component } from 'solid-js';
import { For } from 'solid-js';
import { Skeleton } from '../skeleton';
type tableSkeletonProps = {
rows?: number;
columns?: number;
};
export const TableSkeleton: Component<tableSkeletonProps> = (props) => {
const rows = () => props.rows ?? 5;
const columns = () => props.columns ?? 4;
return (
<div class="w-full">
<For each={Array.from({ length: rows() })}>
{() => (
<div class="flex gap-4 py-3 border-b border-border/80">
<For each={Array.from({ length: columns() })}>
{(_, index) => (
<div class={index() === 0 ? 'flex-1' : 'w-24'}>
<Skeleton class="h-5 w-full" />
</div>
)}
</For>
</div>
)}
</For>
</div>
);
};

View File

@@ -1,170 +0,0 @@
import type { Component, ParentComponent } from 'solid-js';
import { A, useNavigate } from '@solidjs/router';
import { For, Show, Suspense } from 'solid-js';
import { signOut } from '@/modules/auth/auth.services';
import { cn } from '@/modules/shared/style/cn';
import { useThemeStore } from '@/modules/theme/theme.store';
import { Button } from '@/modules/ui/components/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../components/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '../components/sheet';
type MenuItem = {
label: string;
icon: string;
href?: string;
onClick?: () => void;
};
const MenuItemButton: Component<MenuItem> = (props) => {
return (
<>
<Show when={props.onClick}>
<Button class="block" onClick={props.onClick} variant="ghost">
<div class="flex items-center gap-2">
<div class={cn(props.icon, 'size-5 text-muted-foreground')} />
<div>{props.label}</div>
</div>
</Button>
</Show>
<Show when={!props.onClick}>
<Button class="block" as={A} href={props.href!} variant="ghost" activeClass="bg-accent/50! text-accent-foreground!">
<div class="flex items-center gap-2">
<div class={cn(props.icon, 'size-5 text-muted-foreground')} />
<div>{props.label}</div>
</div>
</Button>
</Show>
</>
);
};
const SideNav: Component = () => {
const navigate = useNavigate();
const getMainMenuItems = () => [
{
label: 'Users',
icon: 'i-tabler-users',
href: '/admin/users',
},
];
const getFooterMenuItems = () => [
{
label: 'Settings',
icon: 'i-tabler-settings',
href: '/settings',
},
{
label: 'Logout',
icon: 'i-tabler-logout',
onClick: async () => {
await signOut();
navigate('/login');
},
},
];
return (
<div class="h-full flex flex-col pb-6">
<div class="h-60px flex items-center">
<Button href="/admin" class="text-lg font-bold hover:no-underline gap-1" variant="link" as={A}>
Papra
<span class="font-normal text-base text-muted-foreground">
Admin
</span>
</Button>
</div>
<nav class="flex flex-col gap-0.5 mt-4 text-muted-foreground">
<For each={getMainMenuItems()}>
{menuItem => <MenuItemButton {...menuItem} />}
</For>
</nav>
<div class="flex-1" />
<nav class="flex flex-col gap-0.5 text-muted-foreground">
<For each={getFooterMenuItems()}>
{menuItem => <MenuItemButton {...menuItem} />}
</For>
</nav>
</div>
);
};
const ThemeSwitcher: Component = () => {
const themeStore = useThemeStore();
return (
<>
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'light' })} class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-sun text-lg" />
Light Mode
</DropdownMenuItem>
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'dark' })} class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-moon text-lg" />
Dark Mode
</DropdownMenuItem>
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'system' })} class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-device-laptop text-lg" />
System Mode
</DropdownMenuItem>
</>
);
};
export const AdminLayout: ParentComponent = (props) => {
const themeStore = useThemeStore();
return (
<div class="flex flex-row h-screen min-h-0">
<div class="w-64 border-r border-r-border px-2 flex-shrink-0 hidden md:block">
<SideNav />
</div>
<div class="flex-1 min-h-0 flex flex-col">
<div class="h-60px border-b flex items-center justify-between px-6">
<div>
<Sheet>
<SheetTrigger>
<Button variant="ghost" size="icon" class="md:hidden">
<div class="i-tabler-menu-2 size-6" />
</Button>
</SheetTrigger>
<SheetContent side="left">
<SideNav />
</SheetContent>
</Sheet>
</div>
<div class="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger as={Button} class="text-base" variant="outline" aria-label="Theme switcher">
<div classList={{ 'i-tabler-moon': themeStore.getColorMode() === 'dark', 'i-tabler-sun': themeStore.getColorMode() === 'light' }} />
<div class="ml-2 i-tabler-chevron-down text-muted-foreground text-sm" />
</DropdownMenuTrigger>
<DropdownMenuContent class="w-42">
<ThemeSwitcher />
</DropdownMenuContent>
</DropdownMenu>
<Button as={A} href="/renderings">
<div class="i-tabler-home size-4 mr-1" />
App
</Button>
</div>
</div>
<div class="flex-1 overflow-auto max-w-screen">
<Suspense>
{props.children}
</Suspense>
</div>
</div>
</div>
);
};

View File

@@ -13,6 +13,7 @@ import { cn } from '@/modules/shared/style/cn';
import { UsageWarningCard } from '@/modules/subscriptions/components/usage-warning-card'; import { UsageWarningCard } from '@/modules/subscriptions/components/usage-warning-card';
import { useThemeStore } from '@/modules/theme/theme.store'; import { useThemeStore } from '@/modules/theme/theme.store';
import { Button } from '@/modules/ui/components/button'; import { Button } from '@/modules/ui/components/button';
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../components/dropdown-menu'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../components/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '../components/sheet'; import { Sheet, SheetContent, SheetTrigger } from '../components/sheet';
@@ -131,6 +132,7 @@ export const SidenavLayout: ParentComponent<{
const navigate = useNavigate(); const navigate = useNavigate();
const { getPendingInvitationsCount } = usePendingInvitationsCount(); const { getPendingInvitationsCount } = usePendingInvitationsCount();
const { t } = useI18n(); const { t } = useI18n();
const { hasPermission } = useCurrentUser();
const { promptImport, uploadDocuments } = useDocumentUpload(); const { promptImport, uploadDocuments } = useDocumentUpload();
@@ -240,6 +242,12 @@ export const SidenavLayout: ParentComponent<{
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Show when={hasPermission('bo:access')}>
<Button as={A} href="/admin" variant="outline" class="hidden sm:flex" size="icon">
<div class="i-tabler-settings size-4.5" />
</Button>
</Show>
</div> </div>
</div> </div>
<div class="flex-1 overflow-auto max-w-screen"> <div class="flex-1 overflow-auto max-w-screen">

View File

@@ -11,6 +11,7 @@ const currentUserContext = createContext<{
getLatestOrganizationId: () => string | null; getLatestOrganizationId: () => string | null;
setLatestOrganizationId: (organizationId: string) => void; setLatestOrganizationId: (organizationId: string) => void;
hasPermission: (permission: string) => boolean;
}>(); }>();
export function useCurrentUser() { export function useCurrentUser() {
@@ -42,6 +43,7 @@ export const CurrentUserProvider: ParentComponent = (props) => {
getLatestOrganizationId, getLatestOrganizationId,
setLatestOrganizationId, setLatestOrganizationId,
hasPermission: (permission: string) => query.data?.user.permissions?.includes(permission) ?? false,
}} }}
> >
{props.children} {props.children}

View File

@@ -1,21 +1,13 @@
export type UserMe = {
id: string;
email: string;
planId: string;
name: string;
roles: string[];
};
export type User = { export type User = {
id: string; id: string;
email: string; email: string;
name: string; name: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
provider: string; emailVerified: boolean;
maxApiKeys: number; maxOrganizationCount: number | null;
apiKeysCount: number; };
isEmailVerified: boolean;
customerId: string | null; export type UserMe = User & {
planId: string; permissions: string[];
}; };

View File

@@ -2,6 +2,7 @@ import type { RouteDefinition } from '@solidjs/router';
import { Navigate, useParams } from '@solidjs/router'; import { Navigate, useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query'; import { useQuery } from '@tanstack/solid-query';
import { Match, Show, Suspense, Switch } from 'solid-js'; import { Match, Show, Suspense, Switch } from 'solid-js';
import { adminRoutes } from './modules/admin/admin.routes';
import { ApiKeysPage } from './modules/api-keys/pages/api-keys.page'; import { ApiKeysPage } from './modules/api-keys/pages/api-keys.page';
import { CreateApiKeyPage } from './modules/api-keys/pages/create-api-key.page'; import { CreateApiKeyPage } from './modules/api-keys/pages/create-api-key.page';
import { authPagesPaths } from './modules/auth/auth.constants'; import { authPagesPaths } from './modules/auth/auth.constants';
@@ -197,6 +198,7 @@ export const routes: RouteDefinition[] = [
}, },
], ],
}, },
adminRoutes,
], ],
}, },
{ {

View File

@@ -9,6 +9,7 @@ import {
import { presetAnimations } from 'unocss-preset-animations'; import { presetAnimations } from 'unocss-preset-animations';
import { ssoProviders } from './src/modules/auth/auth.constants'; import { ssoProviders } from './src/modules/auth/auth.constants';
import { documentActivityIcon, fileIcons } from './src/modules/documents/document.models'; import { documentActivityIcon, fileIcons } from './src/modules/documents/document.models';
import { DEFAULT_DOCUMENT_ICON } from './src/modules/documents/documents.constants';
export default defineConfig({ export default defineConfig({
presets: [ presets: [
@@ -116,6 +117,7 @@ export default defineConfig({
}, },
safelist: [ safelist: [
...new Set([ ...new Set([
DEFAULT_DOCUMENT_ICON,
...fileIcons.map(({ icon }) => icon), ...fileIcons.map(({ icon }) => icon),
...Object.values(documentActivityIcon), ...Object.values(documentActivityIcon),
...ssoProviders.map(({ icon }) => icon), ...ssoProviders.map(({ icon }) => icon),

View File

@@ -35,6 +35,7 @@
"dev:reset": "pnpm clean:all && pnpm migrate:up", "dev:reset": "pnpm clean:all && pnpm migrate:up",
"script:send-intake-email": "tsx --env-file-if-exists=.env src/scripts/send-intake-email.script.ts | crowlog-pretty", "script:send-intake-email": "tsx --env-file-if-exists=.env src/scripts/send-intake-email.script.ts | crowlog-pretty",
"stripe:webhook": "stripe listen --forward-to localhost:1221/api/stripe/webhook", "stripe:webhook": "stripe listen --forward-to localhost:1221/api/stripe/webhook",
"script:make-user-admin": "tsx --env-file-if-exists=.env src/scripts/make-user-admin.script.ts",
"maintenance:encrypt-all-documents": "tsx --env-file-if-exists=.env src/scripts/encrypt-all-documents.script.ts" "maintenance:encrypt-all-documents": "tsx --env-file-if-exists=.env src/scripts/encrypt-all-documents.script.ts"
}, },
"dependencies": { "dependencies": {
@@ -47,9 +48,9 @@
"@cadence-mq/driver-memory": "^0.2.0", "@cadence-mq/driver-memory": "^0.2.0",
"@corentinth/chisels": "catalog:", "@corentinth/chisels": "catalog:",
"@corentinth/friendly-ids": "^0.0.1", "@corentinth/friendly-ids": "^0.0.1",
"@crowlog/async-context-plugin": "^2.0.0", "@crowlog/async-context-plugin": "^2.1.0",
"@crowlog/logger": "^2.1.0", "@crowlog/logger": "^2.1.0",
"@hono/node-server": "^1.14.4", "@hono/node-server": "^1.19.6",
"@libsql/client": "^0.14.0", "@libsql/client": "^0.14.0",
"@owlrelay/api-sdk": "^0.0.2", "@owlrelay/api-sdk": "^0.0.2",
"@owlrelay/webhook": "^0.0.3", "@owlrelay/webhook": "^0.0.3",
@@ -65,7 +66,7 @@
"drizzle-kit": "^0.30.6", "drizzle-kit": "^0.30.6",
"drizzle-orm": "^0.38.4", "drizzle-orm": "^0.38.4",
"figue": "^3.1.1", "figue": "^3.1.1",
"hono": "^4.8.2", "hono": "^4.10.7",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",

View File

@@ -1,98 +1,6 @@
/* eslint-disable antfu/no-top-level-await */ /* eslint-disable antfu/no-top-level-await */
import process, { env } from 'node:process'; import { registerShutdownHooks } from './modules/app/graceful-shutdown/graceful-shutdown.usecases';
import { serve } from '@hono/node-server'; import { startApp } from './start';
import { setupDatabase } from './modules/app/database/database';
import { ensureLocalDatabaseDirectoryExists } from './modules/app/database/database.services';
import { createGracefulShutdownService } from './modules/app/graceful-shutdown/graceful-shutdown.services';
import { createServer } from './modules/app/server';
import { parseConfig } from './modules/config/config';
import { createDocumentStorageService } from './modules/documents/storage/documents.storage.services';
import { createIngestionFolderWatcher } from './modules/ingestion-folders/ingestion-folders.usecases';
import { addToGlobalLogContext, createLogger } from './modules/shared/logger/logger';
import { registerTaskDefinitions } from './modules/tasks/tasks.definitions';
import { createTaskServices } from './modules/tasks/tasks.services';
const logger = createLogger({ namespace: 'app-server' }); const { shutdownServices } = await startApp();
registerShutdownHooks({ shutdownServices });
const { config } = await parseConfig({ env });
addToGlobalLogContext({ processMode: config.processMode });
const isWebMode = config.processMode === 'all' || config.processMode === 'web';
const isWorkerMode = config.processMode === 'all' || config.processMode === 'worker';
logger.info({ processMode: config.processMode, isWebMode, isWorkerMode }, 'Starting application');
// Shutdown callback collector
const shutdownService = createGracefulShutdownService({ logger });
const { registerShutdownHandler } = shutdownService;
await ensureLocalDatabaseDirectoryExists({ config });
const { db } = setupDatabase({ ...config.database, registerShutdownHandler });
const documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
const taskServices = createTaskServices({ config });
await taskServices.initialize();
if (isWebMode) {
const { app } = await createServer({ config, db, taskServices, documentsStorageService });
const server = serve(
{
fetch: app.fetch,
port: config.server.port,
hostname: config.server.hostname,
},
({ port }) => logger.info({ port }, 'Server started'),
);
registerShutdownHandler({
id: 'web-server-close',
handler: () => {
server.close();
},
});
}
if (isWorkerMode) {
if (config.ingestionFolder.isEnabled) {
const { startWatchingIngestionFolders } = createIngestionFolderWatcher({
taskServices,
config,
db,
documentsStorageService,
});
await startWatchingIngestionFolders();
}
await registerTaskDefinitions({ taskServices, db, config, documentsStorageService });
taskServices.start();
logger.info('Worker started');
}
// Global error handlers
process.on('uncaughtException', (error) => {
logger.error({ error }, 'Uncaught exception');
setTimeout(() => process.exit(1), 1000); // Give the logger time to flush before exiting
});
process.on('unhandledRejection', (error) => {
logger.error({ error }, 'Unhandled promise rejection');
setTimeout(() => process.exit(1), 1000); // Give the logger time to flush before exiting
});
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
logger.info({ signal }, 'Received shutdown signal, shutting down gracefully...');
await shutdownService.executeShutdownHandlers();
logger.info('Shutdown complete, exiting process');
process.exit(0);
}
process.on('SIGINT', () => void gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => void gracefulShutdown('SIGTERM'));

View File

@@ -0,0 +1,34 @@
import type { Migration } from '../migrations.types';
import { sql } from 'drizzle-orm';
export const dropFts5TriggersMigration = {
name: 'drop-fts-5-triggers',
up: async ({ db }) => {
await db.batch([
db.run(sql`DROP TRIGGER IF EXISTS trigger_documents_fts_insert`),
db.run(sql`DROP TRIGGER IF EXISTS trigger_documents_fts_update`),
db.run(sql`DROP TRIGGER IF EXISTS trigger_documents_fts_delete`),
]);
},
down: async ({ db }) => {
await db.batch([
db.run(sql`
CREATE TRIGGER IF NOT EXISTS trigger_documents_fts_insert AFTER INSERT ON documents BEGIN
INSERT INTO documents_fts(id, name, original_name, content) VALUES (new.id, new.name, new.original_name, new.content);
END
`),
db.run(sql`
CREATE TRIGGER IF NOT EXISTS trigger_documents_fts_update AFTER UPDATE ON documents BEGIN
UPDATE documents_fts SET name = new.name, original_name = new.original_name, content = new.content WHERE id = new.id;
END
`),
db.run(sql`
CREATE TRIGGER IF NOT EXISTS trigger_documents_fts_delete AFTER DELETE ON documents BEGIN
DELETE FROM documents_fts WHERE id = old.id;
END
`),
]);
},
} satisfies Migration;

View File

@@ -121,10 +121,7 @@ describe('migrations registry', () => {
CREATE TABLE "users" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "email" text NOT NULL, "email_verified" integer DEFAULT false NOT NULL, "name" text, "image" text, "max_organization_count" integer ); CREATE TABLE "users" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "email" text NOT NULL, "email_verified" integer DEFAULT false NOT NULL, "name" text, "image" text, "max_organization_count" integer );
CREATE TABLE "webhook_deliveries" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "webhook_id" text NOT NULL, "event_name" text NOT NULL, "request_payload" text NOT NULL, "response_payload" text NOT NULL, "response_status" integer NOT NULL, FOREIGN KEY ("webhook_id") REFERENCES "webhooks"("id") ON UPDATE cascade ON DELETE cascade ); CREATE TABLE "webhook_deliveries" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "webhook_id" text NOT NULL, "event_name" text NOT NULL, "request_payload" text NOT NULL, "response_payload" text NOT NULL, "response_status" integer NOT NULL, FOREIGN KEY ("webhook_id") REFERENCES "webhooks"("id") ON UPDATE cascade ON DELETE cascade );
CREATE TABLE "webhook_events" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "webhook_id" text NOT NULL, "event_name" text NOT NULL, FOREIGN KEY ("webhook_id") REFERENCES "webhooks"("id") ON UPDATE cascade ON DELETE cascade ); CREATE TABLE "webhook_events" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "webhook_id" text NOT NULL, "event_name" text NOT NULL, FOREIGN KEY ("webhook_id") REFERENCES "webhooks"("id") ON UPDATE cascade ON DELETE cascade );
CREATE TABLE "webhooks" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "name" text NOT NULL, "url" text NOT NULL, "secret" text, "enabled" integer DEFAULT true NOT NULL, "created_by" text, "organization_id" text, FOREIGN KEY ("created_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade ); CREATE TABLE "webhooks" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "name" text NOT NULL, "url" text NOT NULL, "secret" text, "enabled" integer DEFAULT true NOT NULL, "created_by" text, "organization_id" text, FOREIGN KEY ("created_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );"
CREATE TRIGGER trigger_documents_fts_delete AFTER DELETE ON documents BEGIN DELETE FROM documents_fts WHERE id = old.id; END;
CREATE TRIGGER trigger_documents_fts_insert AFTER INSERT ON documents BEGIN INSERT INTO documents_fts(id, name, original_name, content) VALUES (new.id, new.name, new.original_name, new.content); END;
CREATE TRIGGER trigger_documents_fts_update AFTER UPDATE ON documents BEGIN UPDATE documents_fts SET name = new.name, original_name = new.original_name, content = new.content WHERE id = new.id; END;"
`); `);
}); });

View File

@@ -15,6 +15,8 @@ import { documentFileEncryptionMigration } from './list/0010-document-file-encry
import { softDeleteOrganizationsMigration } from './list/0011-soft-delete-organizations.migration'; import { softDeleteOrganizationsMigration } from './list/0011-soft-delete-organizations.migration';
import { taggingRuleConditionMatchModeMigration } from './list/0012-tagging-rule-condition-match-mode.migration'; import { taggingRuleConditionMatchModeMigration } from './list/0012-tagging-rule-condition-match-mode.migration';
import { dropFts5TriggersMigration } from './list/0013-drop-fts-5-triggers.migration';
export const migrations: Migration[] = [ export const migrations: Migration[] = [
initialSchemaSetupMigration, initialSchemaSetupMigration,
documentsFtsMigration, documentsFtsMigration,
@@ -28,4 +30,5 @@ export const migrations: Migration[] = [
documentFileEncryptionMigration, documentFileEncryptionMigration,
softDeleteOrganizationsMigration, softDeleteOrganizationsMigration,
taggingRuleConditionMatchModeMigration, taggingRuleConditionMatchModeMigration,
dropFts5TriggersMigration,
]; ];

View File

@@ -0,0 +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,69 @@
import type { RouteDefinitionContext } from '../../app/server.types';
import { createRoleMiddleware, requireAuthentication } from '../../app/auth/auth.middleware';
import { createDocumentsRepository } from '../../documents/documents.repository';
import { createOrganizationsRepository } from '../../organizations/organizations.repository';
import { PERMISSIONS } from '../../roles/roles.constants';
import { createUsersRepository } from '../../users/users.repository';
export function registerAnalyticsRoutes(context: RouteDefinitionContext) {
registerGetUserCountAdminRoute(context);
registerGetDocumentStatsAdminRoute(context);
registerGetOrganizationCountAdminRoute(context);
}
function registerGetUserCountAdminRoute({ app, db }: RouteDefinitionContext) {
const { requirePermissions } = createRoleMiddleware({ db });
app.get(
'/api/admin/users/count',
requireAuthentication(),
requirePermissions({
requiredPermissions: [PERMISSIONS.VIEW_ANALYTICS],
}),
async (context) => {
const usersRepository = createUsersRepository({ db });
const { userCount } = await usersRepository.getUserCount();
return context.json({ userCount });
},
);
}
function registerGetDocumentStatsAdminRoute({ app, db }: RouteDefinitionContext) {
const { requirePermissions } = createRoleMiddleware({ db });
app.get(
'/api/admin/documents/stats',
requireAuthentication(),
requirePermissions({
requiredPermissions: [PERMISSIONS.VIEW_ANALYTICS],
}),
async (context) => {
const documentsRepository = createDocumentsRepository({ db });
const stats = await documentsRepository.getGlobalDocumentsStats();
return context.json(stats);
},
);
}
function registerGetOrganizationCountAdminRoute({ app, db }: RouteDefinitionContext) {
const { requirePermissions } = createRoleMiddleware({ db });
app.get(
'/api/admin/organizations/count',
requireAuthentication(),
requirePermissions({
requiredPermissions: [PERMISSIONS.VIEW_ANALYTICS],
}),
async (context) => {
const organizationsRepository = createOrganizationsRepository({ db });
const { organizationCount } = await organizationsRepository.getOrganizationCount();
return context.json({ organizationCount });
},
);
}

View File

@@ -0,0 +1,74 @@
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('analytics routes - permission protection', () => {
describe('get /api/admin/users/count', () => {
test('when the user has the VIEW_ANALYTICS permission, the request succeeds', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com' },
{ id: 'usr_regular', email: 'user@example.com' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/users/count',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(200);
const body = await response.json();
expect(body).to.eql({ userCount: 2 });
});
test('when the user does not have the VIEW_ANALYTICS 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/users/count',
{ 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/users/count',
{ method: 'GET' },
);
expect(response.status).to.eql(401);
expect(await response.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
});
});

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

@@ -0,0 +1,236 @@
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 users routes - permission protection', () => {
describe('get /api/admin/users', () => {
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' },
{ id: 'usr_regular', email: 'user@example.com', name: 'Regular User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/users',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(200);
const body = (await response.json()) as { users: unknown; totalCount: number };
expect(body.users).to.have.length(2);
expect(body.totalCount).to.eql(2);
});
test('when using search parameter, it filters by email', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
{ id: 'usr_regular', email: 'user@example.com', name: 'Regular User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/users?search=admin',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(200);
const body = await response.json() as { users: { email: string }[]; totalCount: number };
expect(body.users).to.have.length(1);
expect(body.users[0]?.email).to.eql('admin@example.com');
});
test('when using search parameter with user ID, it returns exact match', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
{ id: 'usr_abcdefghijklmnopqrstuvwx', email: 'user@example.com', name: 'Regular User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/users?search=usr_abcdefghijklmnopqrstuvwx',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(200);
const body = await response.json() as { users: { id: string }[]; totalCount: number };
expect(body.users).to.have.length(1);
expect(body.users[0]?.id).to.eql('usr_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/users',
{ 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/users',
{ method: 'GET' },
);
expect(response.status).to.eql(401);
expect(await response.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
});
describe('get /api/admin/users/:userId', () => {
test('when the user has the VIEW_USERS permission, the request succeeds and returns user details', async () => {
const targetUserId = 'usr_123456789012345678901234';
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
{ id: targetUserId, email: 'target@example.com', name: 'Target User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
organizations: [
{ id: 'org_1', name: 'Organization 1' },
],
organizationMembers: [
{ userId: targetUserId, organizationId: 'org_1', role: 'owner' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
`/api/admin/users/${targetUserId}`,
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(200);
const body = await response.json() as {
user: { id: string; email: string };
organizations: { id: string; name: string }[];
roles: string[];
};
expect(body.user.id).to.eql(targetUserId);
expect(body.user.email).to.eql('target@example.com');
expect(body.organizations).to.have.length(1);
expect(body.organizations[0]?.id).to.eql('org_1');
expect(body.roles).to.be.an('array');
});
test('when the user 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/users/usr_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 targetUserId = 'usr_123456789012345678901234';
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_regular', email: 'user@example.com' },
{ id: targetUserId, email: 'target@example.com' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
`/api/admin/users/${targetUserId}`,
{ 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({
users: [
{ id: 'usr_target', email: 'target@example.com' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/users/usr_target',
{ method: 'GET' },
);
expect(response.status).to.eql(401);
expect(await response.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
});
});

View File

@@ -0,0 +1,83 @@
import type { RouteDefinitionContext } from '../../app/server.types';
import { z } from 'zod';
import { createRoleMiddleware, requireAuthentication } from '../../app/auth/auth.middleware';
import { createOrganizationsRepository } from '../../organizations/organizations.repository';
import { PERMISSIONS } from '../../roles/roles.constants';
import { createRolesRepository } from '../../roles/roles.repository';
import { validateParams, validateQuery } from '../../shared/validation/validation';
import { createUsersRepository } from '../../users/users.repository';
import { userIdSchema } from '../../users/users.schemas';
export function registerUserManagementRoutes(context: RouteDefinitionContext) {
registerListUsersRoute(context);
registerGetUserDetailRoute(context);
}
function registerListUsersRoute({ app, db }: RouteDefinitionContext) {
const { requirePermissions } = createRoleMiddleware({ db });
app.get(
'/api/admin/users',
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 usersRepository = createUsersRepository({ db });
const { search, pageIndex, pageSize } = context.req.valid('query');
const { users, totalCount } = await usersRepository.listUsers({
search,
pageIndex,
pageSize,
});
return context.json({
users,
totalCount,
pageIndex,
pageSize,
});
},
);
}
function registerGetUserDetailRoute({ app, db }: RouteDefinitionContext) {
const { requirePermissions } = createRoleMiddleware({ db });
app.get(
'/api/admin/users/:userId',
requireAuthentication(),
requirePermissions({
requiredPermissions: [PERMISSIONS.VIEW_USERS],
}),
validateParams(z.object({
userId: userIdSchema,
})),
async (context) => {
const usersRepository = createUsersRepository({ db });
const organizationsRepository = createOrganizationsRepository({ db });
const rolesRepository = createRolesRepository({ db });
const { userId } = context.req.valid('param');
const { user } = await usersRepository.getUserByIdOrThrow({ userId });
const { organizations } = await organizationsRepository.getUserOrganizations({ userId });
const { roles } = await rolesRepository.getUserRoles({ userId });
return context.json({
user,
organizations,
roles,
});
},
);
}

View File

@@ -2,6 +2,7 @@ import type { Document } from '../../documents/documents.types';
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from '../../app/database/database.test-utils'; import { createInMemoryDatabase } from '../../app/database/database.test-utils';
import { createServer } from '../../app/server'; import { createServer } from '../../app/server';
import { createTestServerDependencies } from '../../app/server.test-utils';
import { overrideConfig } from '../../config/config.test-utils'; import { overrideConfig } from '../../config/config.test-utils';
import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants'; import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants';
@@ -13,7 +14,7 @@ describe('api-key e2e', () => {
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }], organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
}); });
const { app } = await createServer({ const { app } = createServer(createTestServerDependencies({
db, db,
config: overrideConfig({ config: overrideConfig({
env: 'test', env: 'test',
@@ -21,7 +22,7 @@ describe('api-key e2e', () => {
driver: 'in-memory', driver: 'in-memory',
}, },
}), }),
}); }));
const createApiKeyResponse = await app.request( const createApiKeyResponse = await app.request(
'/api/api-keys', '/api/api-keys',

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from 'vitest';
import { ensureAuthSecretIsNotDefaultInProduction } from './auth.config.models';
import { createAuthSecretIsDefaultError } from './auth.errors';
describe('auth config models', () => {
describe('ensureAuthSecretIsNotDefaultInProduction', () => {
const defaultAuthSecret = 'papra-default-auth-secret-change-me';
test('throws an error if in production and auth secret is the default one', () => {
expect(() =>
ensureAuthSecretIsNotDefaultInProduction({
config: { auth: { secret: defaultAuthSecret }, env: 'production' },
defaultAuthSecret,
}),
).toThrow(createAuthSecretIsDefaultError());
expect(() =>
ensureAuthSecretIsNotDefaultInProduction({
config: { auth: { secret: defaultAuthSecret }, env: 'dev' },
defaultAuthSecret,
}),
).not.toThrow();
expect(() =>
ensureAuthSecretIsNotDefaultInProduction({
config: { auth: { secret: 'a-non-default-secure-secret' }, env: 'production' },
defaultAuthSecret,
}),
).not.toThrow();
});
});
});

View File

@@ -0,0 +1,14 @@
import { DEFAULT_AUTH_SECRET } from './auth.constants';
import { createAuthSecretIsDefaultError } from './auth.errors';
export function ensureAuthSecretIsNotDefaultInProduction({
config,
defaultAuthSecret = DEFAULT_AUTH_SECRET,
}: {
config: { auth: { secret: string }; env: string };
defaultAuthSecret?: string;
}) {
if (config.env === 'production' && config.auth.secret === defaultAuthSecret) {
throw createAuthSecretIsDefaultError();
}
}

View File

@@ -2,6 +2,7 @@ import type { ConfigDefinition } from 'figue';
import { z } from 'zod'; import { z } from 'zod';
import { booleanishSchema } from '../../config/config.schemas'; import { booleanishSchema } from '../../config/config.schemas';
import { parseJson } from '../../intake-emails/intake-emails.schemas'; import { parseJson } from '../../intake-emails/intake-emails.schemas';
import { DEFAULT_AUTH_SECRET } from './auth.constants';
const customOAuthProviderSchema = z.object({ const customOAuthProviderSchema = z.object({
providerId: z.string(), providerId: z.string(),
@@ -26,9 +27,9 @@ const customOAuthProviderSchema = z.object({
export const authConfig = { export const authConfig = {
secret: { secret: {
doc: 'The secret for the auth', doc: 'The secret for the auth, it should be at least 32 characters long, you can generate a secure one using `openssl rand -hex 48`',
schema: z.string(), schema: z.string({ required_error: 'Please provide an auth secret using the AUTH_SECRET environment variable, you can use `openssl rand -hex 48` to generate a secure one' }).min(32),
default: 'change-me-for-god-sake', default: DEFAULT_AUTH_SECRET,
env: 'AUTH_SECRET', env: 'AUTH_SECRET',
}, },
isRegistrationEnabled: { isRegistrationEnabled: {

View File

@@ -0,0 +1,10 @@
import { describe, expect, test } from 'vitest';
import { DEFAULT_AUTH_SECRET } from './auth.constants';
describe('auth constants', () => {
describe('default auth secret', () => {
test('the default auth secret should be at least 32 characters long', () => {
expect(DEFAULT_AUTH_SECRET.length).toBeGreaterThanOrEqual(32);
});
});
});

View File

@@ -0,0 +1 @@
export const DEFAULT_AUTH_SECRET = 'papra-default-auth-secret-change-me';

View File

@@ -17,3 +17,10 @@ export const createForbiddenEmailDomainError = createErrorFactory({
code: 'auth.forbidden_email_domain', code: 'auth.forbidden_email_domain',
statusCode: 403, statusCode: 403,
}); });
export const createAuthSecretIsDefaultError = createErrorFactory({
code: 'auth.config.secret_is_default',
message: 'In production, the auth secret must not be the default one. Please set a secure auth secret using the AUTH_SECRET environment variable.',
statusCode: 500,
isInternal: true,
});

View File

@@ -0,0 +1,199 @@
import type { ServerInstanceGenerics } from '../server.types';
import { Hono } from 'hono';
import { describe, expect, test } from 'vitest';
import { PERMISSIONS } from '../../roles/roles.constants';
import { createInMemoryDatabase } from '../database/database.test-utils';
import { registerErrorMiddleware } from '../middlewares/errors.middleware';
import { createRoleMiddleware } from './auth.middleware';
describe('createRoleMiddleware', () => {
const permissionsByRole = {
admin: [PERMISSIONS.BO_ACCESS],
};
const createTestServer = ({ loggedInUserId}: { loggedInUserId: string | null }) => {
const app = new Hono<ServerInstanceGenerics>();
registerErrorMiddleware({ app });
// Mock the context variables before the middleware
app.use('*', async (c, next) => {
c.set('userId', loggedInUserId);
await next();
});
return { app };
};
describe('when the user has the required permission', () => {
test('the request is allowed to proceed', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'user@example.com' }],
userRoles: [
{ userId: 'user-1', role: 'admin' },
],
});
const { app } = createTestServer({ loggedInUserId: 'user-1' });
const { requirePermissions } = createRoleMiddleware({ db, permissionsByRole });
app.get(
'/protected',
requirePermissions({ requiredPermissions: [PERMISSIONS.BO_ACCESS] }),
async c => c.json({ success: true }),
);
const response = await app.request('/protected', { method: 'GET' });
expect(response.status).to.eql(200);
expect(await response.json()).to.eql({ success: true });
});
});
describe('when the user does not have the required permission', () => {
test('a 401 unauthorized error is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'user@example.com' }],
});
const { app } = createTestServer({ loggedInUserId: 'user-1' });
const { requirePermissions } = createRoleMiddleware({ db, permissionsByRole });
app.get(
'/protected',
requirePermissions({ requiredPermissions: [PERMISSIONS.BO_ACCESS] }),
async c => c.json({ success: true }),
);
const response = await app.request('/protected', { method: 'GET' });
expect(response.status).to.eql(401);
expect(await response.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
});
describe('when the user is not authenticated', () => {
test('a 401 unauthorized error is returned', async () => {
const { db } = await createInMemoryDatabase();
const { app } = createTestServer({ loggedInUserId: null });
const { requirePermissions } = createRoleMiddleware({ db, permissionsByRole });
app.get(
'/protected',
requirePermissions({ requiredPermissions: [PERMISSIONS.BO_ACCESS] }),
async c => c.json({ success: true }),
);
const response = await app.request('/protected', { method: 'GET' });
expect(response.status).to.eql(401);
expect(await response.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
});
describe('when the user has multiple permissions and one matches', () => {
test('the request is allowed to proceed', async () => {
const extendedPermissionsByRole = {
admin: [PERMISSIONS.BO_ACCESS, PERMISSIONS.VIEW_USERS],
};
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'user@example.com' }],
userRoles: [
{ userId: 'user-1', role: 'admin' },
],
});
const { app } = createTestServer({ loggedInUserId: 'user-1' });
const { requirePermissions } = createRoleMiddleware({ db, permissionsByRole: extendedPermissionsByRole });
app.get(
'/protected',
requirePermissions({ requiredPermissions: [PERMISSIONS.VIEW_USERS] }),
async c => c.json({ success: true }),
);
const response = await app.request('/protected', { method: 'GET' });
expect(response.status).to.eql(200);
expect(await response.json()).to.eql({ success: true });
});
});
describe('when multiple permissions are required', () => {
test('the request is allowed if the user has all required permissions', async () => {
const extendedPermissionsByRole = {
admin: [PERMISSIONS.BO_ACCESS, PERMISSIONS.VIEW_USERS],
};
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'user@example.com' }],
userRoles: [
{ userId: 'user-1', role: 'admin' },
],
});
const { app } = createTestServer({ loggedInUserId: 'user-1' });
const { requirePermissions } = createRoleMiddleware({ db, permissionsByRole: extendedPermissionsByRole });
app.get(
'/protected',
requirePermissions({ requiredPermissions: [PERMISSIONS.BO_ACCESS, PERMISSIONS.VIEW_USERS] }),
async c => c.json({ success: true }),
);
const response = await app.request('/protected', { method: 'GET' });
expect(response.status).to.eql(200);
expect(await response.json()).to.eql({ success: true });
});
test('a 401 error is returned if the user is missing one of the required permissions', async () => {
const extendedPermissionsByRole = {
admin: [PERMISSIONS.BO_ACCESS],
};
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'user@example.com' }],
userRoles: [
{ userId: 'user-1', role: 'admin' },
],
});
const { app } = createTestServer({ loggedInUserId: 'user-1' });
const { requirePermissions } = createRoleMiddleware({ db, permissionsByRole: extendedPermissionsByRole });
app.get(
'/protected',
requirePermissions({ requiredPermissions: [PERMISSIONS.BO_ACCESS, PERMISSIONS.VIEW_USERS] }),
async c => c.json({ success: true }),
);
const response = await app.request('/protected', { method: 'GET' });
expect(response.status).to.eql(401);
expect(await response.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
});
});

View File

@@ -1,6 +1,12 @@
import type { ApiKeyPermissions } from '../../api-keys/api-keys.types'; import type { ApiKeyPermissions } from '../../api-keys/api-keys.types';
import type { Permission, Role } from '../../roles/roles.types';
import type { Database } from '../database/database.types';
import type { Context } from '../server.types'; import type { Context } from '../server.types';
import { createMiddleware } from 'hono/factory'; import { createMiddleware } from 'hono/factory';
import { PERMISSIONS_BY_ROLE } from '../../roles/roles.constants';
import { getPermissionsForRoles } from '../../roles/roles.methods';
import { createRolesRepository } from '../../roles/roles.repository';
import { isNil } from '../../shared/utils';
import { createUnauthorizedError } from './auth.errors'; import { createUnauthorizedError } from './auth.errors';
import { isAuthenticationValid } from './auth.models'; import { isAuthenticationValid } from './auth.models';
@@ -20,3 +26,33 @@ export function requireAuthentication({ apiKeyPermissions }: { apiKeyPermissions
await next(); await next();
}); });
} }
/**
* Middleware to require specific permissions for the authenticated user.
*/
export function createRoleMiddleware({ db, permissionsByRole = PERMISSIONS_BY_ROLE }: { db: Database; permissionsByRole?: Record<Role, Readonly<Permission[]>> }) {
const rolesRepository = createRolesRepository({ db });
return {
requirePermissions: ({ requiredPermissions }: { requiredPermissions: Permission[] }) =>
createMiddleware(async (context: Context, next) => {
const userId = context.get('userId');
if (isNil(userId)) {
throw createUnauthorizedError();
}
const { roles } = await rolesRepository.getUserRoles({ userId });
const { permissions } = getPermissionsForRoles({ roles, permissionsByRole });
const hasAllPermissions = requiredPermissions.every(permission => permissions.includes(permission));
if (!hasAllPermissions) {
throw createUnauthorizedError();
}
await next();
}),
};
}

View File

@@ -1,6 +1,6 @@
import type { Config } from '../../config/config.types'; import type { Config } from '../../config/config.types';
import type { TrackingServices } from '../../tracking/tracking.services';
import type { Database } from '../database/database.types'; import type { Database } from '../database/database.types';
import type { EventServices } from '../events/events.services';
import type { AuthEmailsServices } from './auth.emails.services'; import type { AuthEmailsServices } from './auth.emails.services';
import { expo } from '@better-auth/expo'; import { expo } from '@better-auth/expo';
import { betterAuth } from 'better-auth'; import { betterAuth } from 'better-auth';
@@ -21,12 +21,12 @@ export function getAuth({
db, db,
config, config,
authEmailsServices, authEmailsServices,
trackingServices, eventServices,
}: { }: {
db: Database; db: Database;
config: Config; config: Config;
authEmailsServices: AuthEmailsServices; authEmailsServices: AuthEmailsServices;
trackingServices: TrackingServices; eventServices: EventServices;
}) { }) {
const { secret } = config.auth; const { secret } = config.auth;
@@ -86,9 +86,13 @@ export function getAuth({
throw createForbiddenEmailDomainError(); throw createForbiddenEmailDomainError();
} }
}, },
after: async ({ id: userId, email }) => { after: async ({ id: userId, email, createdAt }) => {
logger.info({ userId }, 'User signed up'); logger.info({ userId }, 'User signed up');
trackingServices.captureUserEvent({ userId, event: 'User signed up', properties: { $set: { email } } });
eventServices.emitEvent({
eventName: 'user.created',
payload: { userId, email, createdAt },
});
}, },
}, },
}, },

View File

@@ -1,7 +1,9 @@
import { describe, expect, test, vi } from 'vitest'; import { describe, expect, test } from 'vitest';
import { overrideConfig } from '../../../config/config.test-utils'; import { overrideConfig } from '../../../config/config.test-utils';
import { createInMemoryDatabase } from '../../database/database.test-utils'; import { createInMemoryDatabase } from '../../database/database.test-utils';
import { createEventServices } from '../../events/events.services';
import { createServer } from '../../server'; import { createServer } from '../../server';
import { createTestServerDependencies } from '../../server.test-utils';
import { createAuthEmailsServices } from '../auth.emails.services'; import { createAuthEmailsServices } from '../auth.emails.services';
import { getAuth } from '../auth.services'; import { getAuth } from '../auth.services';
@@ -34,9 +36,9 @@ describe('email verification e2e', () => {
}); });
const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices }); const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices });
const { auth } = getAuth({ db, config, authEmailsServices, trackingServices: { captureUserEvent: vi.fn(), shutdown: vi.fn() } }); const { auth } = getAuth({ db, config, authEmailsServices, eventServices: createEventServices() });
const { app } = await createServer({ db, config, auth }); const { app } = createServer(createTestServerDependencies({ db, config, auth }));
const response = await app.request('/api/auth/sign-up/email', { const response = await app.request('/api/auth/sign-up/email', {
method: 'POST', method: 'POST',
@@ -75,9 +77,9 @@ describe('email verification e2e', () => {
}); });
const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices }); const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices });
const { auth } = getAuth({ db, config, authEmailsServices, trackingServices: { captureUserEvent: vi.fn(), shutdown: vi.fn() } }); const { auth } = getAuth({ db, config, authEmailsServices, eventServices: createEventServices() });
const { app } = await createServer({ db, config, auth }); const { app } = createServer(createTestServerDependencies({ db, config, auth }));
// First, sign up // First, sign up
await app.request('/api/auth/sign-up/email', { await app.request('/api/auth/sign-up/email', {
@@ -135,9 +137,9 @@ describe('email verification e2e', () => {
}); });
const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices }); const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices });
const { auth } = getAuth({ db, config, authEmailsServices, trackingServices: { captureUserEvent: vi.fn(), shutdown: vi.fn() } }); const { auth } = getAuth({ db, config, authEmailsServices, eventServices: createEventServices() });
const { app } = await createServer({ db, config, auth }); const { app } = createServer(createTestServerDependencies({ db, config, auth }));
const response = await app.request('/api/auth/sign-up/email', { const response = await app.request('/api/auth/sign-up/email', {
method: 'POST', method: 'POST',
@@ -166,9 +168,9 @@ describe('email verification e2e', () => {
}); });
const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices }); const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices });
const { auth } = getAuth({ db, config, authEmailsServices, trackingServices: { captureUserEvent: vi.fn(), shutdown: vi.fn() } }); const { auth } = getAuth({ db, config, authEmailsServices, eventServices: createEventServices() });
const { app } = await createServer({ db, config, auth }); const { app } = createServer(createTestServerDependencies({ db, config, auth }));
// Sign up // Sign up
await app.request('/api/auth/sign-up/email', { await app.request('/api/auth/sign-up/email', {

View File

@@ -6,6 +6,7 @@ import { apiKeyOrganizationsTable, apiKeysTable } from '../../api-keys/api-keys.
import { documentsTable } from '../../documents/documents.table'; import { documentsTable } from '../../documents/documents.table';
import { intakeEmailsTable } from '../../intake-emails/intake-emails.tables'; import { intakeEmailsTable } from '../../intake-emails/intake-emails.tables';
import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from '../../organizations/organizations.table'; import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from '../../organizations/organizations.table';
import { userRolesTable } from '../../roles/roles.table';
import { organizationSubscriptionsTable } from '../../subscriptions/subscriptions.tables'; import { organizationSubscriptionsTable } from '../../subscriptions/subscriptions.tables';
import { taggingRuleActionsTable, taggingRuleConditionsTable, taggingRulesTable } from '../../tagging-rules/tagging-rules.tables'; import { taggingRuleActionsTable, taggingRuleConditionsTable, taggingRulesTable } from '../../tagging-rules/tagging-rules.tables';
import { documentsTagsTable, tagsTable } from '../../tags/tags.table'; import { documentsTagsTable, tagsTable } from '../../tags/tags.table';
@@ -49,6 +50,7 @@ const seedTables = {
webhookEvents: webhookEventsTable, webhookEvents: webhookEventsTable,
webhookDeliveries: webhookDeliveriesTable, webhookDeliveries: webhookDeliveriesTable,
organizationInvitations: organizationInvitationsTable, organizationInvitations: organizationInvitationsTable,
userRoles: userRolesTable,
} as const; } as const;
type SeedTablesRows = { type SeedTablesRows = {

View File

@@ -1,4 +1,4 @@
import type { ShutdownHandlerRegistration } from '../graceful-shutdown/graceful-shutdown.services'; import type { ShutdownServices } from '../graceful-shutdown/graceful-shutdown.services';
import { createClient } from '@libsql/client'; import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql'; import { drizzle } from 'drizzle-orm/libsql';
@@ -8,18 +8,18 @@ function setupDatabase({
url, url,
authToken, authToken,
encryptionKey, encryptionKey,
registerShutdownHandler, shutdownServices,
}: { }: {
url: string; url: string;
authToken?: string; authToken?: string;
encryptionKey?: string; encryptionKey?: string;
registerShutdownHandler?: ShutdownHandlerRegistration; shutdownServices?: ShutdownServices;
}) { }) {
const client = createClient({ url, authToken, encryptionKey }); const client = createClient({ url, authToken, encryptionKey });
const db = drizzle(client); const db = drizzle(client);
registerShutdownHandler?.({ shutdownServices?.registerShutdownHandler({
id: 'database-client-close', id: 'database-client-close',
handler: () => client.close(), handler: () => client.close(),
}); });

View File

@@ -0,0 +1,27 @@
import type { DocumentSearchServices } from '../../documents/document-search/document-search.types';
import type { TrackingServices } from '../../tracking/tracking.services';
import type { Database } from '../database/database.types';
import type { EventServices } from './events.services';
import { registerSyncDocumentSearchEventHandlers } from '../../documents/document-search/events/sync-document-search.handlers';
import { registerInsertActivityLogOnDocumentCreatedHandler } from '../../documents/events/activity-log.document-created';
import { registerInsertActivityLogOnDocumentRestoredHandler } from '../../documents/events/activity-log.document-restored';
import { registerInsertActivityLogOnDocumentTrashedHandler } from '../../documents/events/activity-log.document-trashed';
import { registerInsertActivityLogOnDocumentUpdatedHandler } from '../../documents/events/activity-log.document-updated';
import { registerTrackDocumentCreatedHandler } from '../../documents/events/tracking.document-created';
import { registerTriggerWebhooksOnDocumentCreatedHandler } from '../../documents/events/webhook.document-created';
import { registerTriggerWebhooksOnDocumentTrashedHandler } from '../../documents/events/webhook.document-trashed';
import { registerTriggerWebhooksOnDocumentUpdatedHandler } from '../../documents/events/webhook.document-updated';
import { registerTrackingUserCreatedEventHandler } from '../../users/event-handlers/tracking.user-created';
export function registerEventHandlers(deps: { trackingServices: TrackingServices; eventServices: EventServices; db: Database; documentSearchServices: DocumentSearchServices }) {
registerTrackingUserCreatedEventHandler(deps);
registerTriggerWebhooksOnDocumentCreatedHandler(deps);
registerInsertActivityLogOnDocumentCreatedHandler(deps);
registerTrackDocumentCreatedHandler(deps);
registerTriggerWebhooksOnDocumentTrashedHandler(deps);
registerInsertActivityLogOnDocumentTrashedHandler(deps);
registerInsertActivityLogOnDocumentRestoredHandler(deps);
registerTriggerWebhooksOnDocumentUpdatedHandler(deps);
registerInsertActivityLogOnDocumentUpdatedHandler(deps);
registerSyncDocumentSearchEventHandlers(deps);
}

View File

@@ -0,0 +1,193 @@
import { createNoopLogger } from '@crowlog/logger';
import { describe, expect, test } from 'vitest';
import { nextTick } from '../../shared/async/defer.test-utils';
import { createEventServices } from './events.services';
type TestEvents = {
'user.created': { userId: string; email: string };
'user.deleted': { userId: string };
};
describe('events services', () => {
describe('emitEvent', () => {
test('registered handlers are called with the event payload when an event is emitted', async () => {
const eventsServices = createEventServices<TestEvents>({ logger: createNoopLogger() });
const handlerCalls: unknown[] = [];
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'test-handler',
handler: async (payload, meta) => {
handlerCalls.push({ payload, meta });
},
});
eventsServices.emitEvent({
eventName: 'user.created',
payload: { userId: '123', email: 'test@example.com' },
eventId: 'evt_test',
now: new Date('2024-01-01'),
});
expect(handlerCalls).to.deep.equal([]);
await nextTick();
expect(handlerCalls).to.deep.equal([{
payload: { userId: '123', email: 'test@example.com' },
meta: { emittedAt: new Date('2024-01-01'), eventId: 'evt_test' },
}]);
});
test('multiple handlers registered for the same event are all called', async () => {
const eventsServices = createEventServices<TestEvents>({ logger: createNoopLogger() });
const handlerCalls: unknown[] = [];
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'handler-1',
handler: async (payload) => {
handlerCalls.push({ handler: 'handler-1', payload });
},
});
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'handler-2',
handler: async (payload) => {
handlerCalls.push({ handler: 'handler-2', payload });
},
});
eventsServices.emitEvent({
eventName: 'user.created',
payload: { userId: '456', email: 'test2@example.com' },
eventId: 'evt_multi',
now: new Date('2024-02-01'),
});
await nextTick();
expect(handlerCalls).to.have.length(2);
expect(handlerCalls).to.deep.include({ handler: 'handler-1', payload: { userId: '456', email: 'test2@example.com' } });
expect(handlerCalls).to.deep.include({ handler: 'handler-2', payload: { userId: '456', email: 'test2@example.com' } });
});
test('emitting an event with no registered handlers does not throw an error', async () => {
const eventsServices = createEventServices<TestEvents>({ logger: createNoopLogger() });
expect(async () => {
eventsServices.emitEvent({
eventName: 'user.deleted',
payload: { userId: '789' },
eventId: 'evt_no_handlers',
now: new Date('2024-03-01'),
});
await nextTick();
}).to.not.throw();
});
test('handlers are only called for their specific registered event', async () => {
const eventsServices = createEventServices<TestEvents>({ logger: createNoopLogger() });
const handlerCalls: unknown[] = [];
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'created-handler',
handler: async () => {
handlerCalls.push('created');
},
});
eventsServices.onEvent({
eventName: 'user.deleted',
handlerName: 'deleted-handler',
handler: async () => {
handlerCalls.push('deleted');
},
});
eventsServices.emitEvent({
eventName: 'user.created',
payload: { userId: '111', email: 'test@example.com' },
eventId: 'evt_created',
now: new Date('2024-04-01'),
});
await nextTick();
expect(handlerCalls).to.deep.equal(['created']);
});
test('handler errors are caught and do not crash the application', async () => {
const eventsServices = createEventServices<TestEvents>({ logger: createNoopLogger() });
const handlerCalls: unknown[] = [];
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'failing-handler',
handler: async () => {
throw new Error('Handler failed');
},
});
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'successful-handler',
handler: async () => {
handlerCalls.push('success');
},
});
eventsServices.emitEvent({
eventName: 'user.created',
payload: { userId: '222', email: 'error@example.com' },
eventId: 'evt_error',
now: new Date('2024-05-01'),
});
await nextTick();
expect(handlerCalls).to.deep.equal(['success']);
});
});
describe('onEvent', () => {
test('registering a handler with a duplicate name for the same event throws an error', () => {
const eventsServices = createEventServices<TestEvents>({ logger: createNoopLogger() });
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'duplicate-handler',
handler: async () => {},
});
expect(() => {
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'duplicate-handler',
handler: async () => {},
});
}).to.throw('Duplicate handler name "duplicate-handler" for event "user.created"');
});
test('the same handler name can be used for different events', () => {
const eventsServices = createEventServices<TestEvents>({ logger: createNoopLogger() });
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'shared-handler-name',
handler: async () => {},
});
expect(() => {
eventsServices.onEvent({
eventName: 'user.deleted',
handlerName: 'shared-handler-name',
handler: async () => {},
});
}).to.not.throw();
});
});
});

View File

@@ -0,0 +1,83 @@
import type { Logger } from '../../shared/logger/logger';
import type { AppEvents } from './events.types';
import { safely } from '@corentinth/chisels';
import { createError } from '../../shared/errors/errors';
import { createLogger, wrapWithLoggerContext } from '../../shared/logger/logger';
import { generateId } from '../../shared/random/ids';
import { isNil } from '../../shared/utils';
type HandlerMeta = {
emittedAt: Date;
eventId: string;
};
export type EventServices = ReturnType<typeof createEventServices<AppEvents>>;
export function createEventServices<T extends Record<string, Record<string, unknown>> = AppEvents>({ logger = createLogger({ namespace: 'events-services' }) }: { logger?: Logger } = {}) {
const handlers = new Map<keyof T, { handlerName: string; handler: (payload: T[keyof T], meta: HandlerMeta) => Promise<void> | void }[]>();
return {
onEvent<K extends keyof T>({ eventName, handlerName, handler }: {
eventName: K;
handlerName: string;
handler: (payload: T[K], meta: HandlerMeta) => Promise<void>;
}) {
const isDuplicateName = handlers.get(eventName)?.some(h => h.handlerName === handlerName) ?? false;
if (isDuplicateName) {
throw createError({
message: `Duplicate handler name "${handlerName}" for event "${String(eventName)}"`,
code: 'events.duplicate_handler_name',
statusCode: 500,
isInternal: true,
});
}
handlers.set(eventName, [
...(handlers.get(eventName) ?? []),
{ handlerName, handler: handler as (payload: T[keyof T], meta: HandlerMeta) => Promise<void> },
]);
},
emitEvent<K extends keyof T>({
eventName,
payload,
eventId = generateId({ prefix: 'evt' }),
now = new Date(),
}: {
eventName: K;
payload: T[K];
eventId?: string;
now?: Date;
}) {
const eventHandlers = handlers.get(eventName);
if (isNil(eventHandlers) || eventHandlers.length === 0) {
logger.debug(`No handlers for event: ${String(eventName)}`);
return;
}
logger.debug({
eventName,
eventId,
handlerCount: eventHandlers.length,
handlerNames: eventHandlers.map(({ handlerName }) => handlerName),
}, 'Event emitted');
setImmediate(async () => {
await Promise.allSettled(eventHandlers.map(async ({ handlerName, handler }) => {
await wrapWithLoggerContext({ eventId, eventName, handlerName }, async () => {
const [, error] = await safely(async () => handler(payload, { emittedAt: now, eventId }));
if (error) {
logger.error({ error }, 'Error in event handler');
return;
}
logger.info('Event handler executed successfully');
});
}));
});
},
};
}

View File

@@ -0,0 +1,19 @@
import type { EventServices } from './events.services';
export function createTestEventServices() {
const emittedEvents: { eventName: string; payload: Record<string, unknown> }[] = [];
const services = {
onEvent() {},
emitEvent({ eventName, payload }) {
emittedEvents.push({ eventName, payload });
},
} satisfies EventServices;
return {
...services,
getEmittedEvents() {
return emittedEvents;
},
};
}

View File

@@ -0,0 +1,4 @@
import type { DocumentEvents } from '../../documents/documents.events.types';
import type { UserEvents } from '../../users/users.events.types';
export type AppEvents = UserEvents & DocumentEvents;

View File

@@ -7,6 +7,8 @@ export type ShutdownHandlerConfig = {
}; };
export type ShutdownHandlerRegistration = (handlerConfig: ShutdownHandlerConfig) => void; export type ShutdownHandlerRegistration = (handlerConfig: ShutdownHandlerConfig) => void;
export type ShutdownServices = ReturnType<typeof createGracefulShutdownService>;
export function createGracefulShutdownService({ logger = createLogger({ namespace: 'graceful-shutdown' }) }: { logger?: Logger } = {}) { export function createGracefulShutdownService({ logger = createLogger({ namespace: 'graceful-shutdown' }) }: { logger?: Logger } = {}) {
const shutdownHandlers: ShutdownHandlerConfig[] = []; const shutdownHandlers: ShutdownHandlerConfig[] = [];

View File

@@ -0,0 +1,30 @@
import type { Logger } from '../../shared/logger/logger';
import type { ShutdownServices } from './graceful-shutdown.services';
import process from 'node:process';
import { createLogger } from '../../shared/logger/logger';
async function gracefulShutdown({ signal, cause, shutdownServices, logger, exitCode = 0 }: { signal?: string; cause: string; shutdownServices: ShutdownServices; logger: Logger; exitCode?: number }) {
logger.info({ signal, cause }, 'Shutting down gracefully...');
await shutdownServices.executeShutdownHandlers();
logger.info('Shutdown complete, exiting process');
// Let logs flush
setTimeout(() => process.exit(exitCode), 500);
}
export function registerShutdownHooks({ shutdownServices, logger = createLogger({ namespace: 'graceful-shutdown' }) }: { shutdownServices: ShutdownServices; logger?: Logger }) {
process.on('uncaughtException', (error) => {
logger.error({ error }, 'Uncaught exception');
void gracefulShutdown({ cause: 'uncaughtException', shutdownServices, logger, exitCode: 1 });
});
process.on('unhandledRejection', (error) => {
logger.error({ error }, 'Unhandled promise rejection');
void gracefulShutdown({ cause: 'unhandledRejection', shutdownServices, logger, exitCode: 1 });
});
process.on('SIGINT', () => void gracefulShutdown({ cause: 'Interrupt signal', signal: 'SIGINT', shutdownServices, logger }));
process.on('SIGTERM', () => void gracefulShutdown({ cause: 'Termination signal', signal: 'SIGTERM', shutdownServices, logger }));
}

View File

@@ -3,13 +3,14 @@ import { describe, expect, test } from 'vitest';
import { overrideConfig } from '../../../config/config.test-utils'; import { overrideConfig } from '../../../config/config.test-utils';
import { createInMemoryDatabase } from '../../database/database.test-utils'; import { createInMemoryDatabase } from '../../database/database.test-utils';
import { createServer } from '../../server'; import { createServer } from '../../server';
import { createTestServerDependencies } from '../../server.test-utils';
describe('health check routes e2e', () => { describe('health check routes e2e', () => {
describe('health check', () => { describe('health check', () => {
describe('the /api/health is a publicly accessible route that provides health information about the server', () => { describe('the /api/health is a publicly accessible route that provides health information about the server', () => {
test('when the database is healthy, the /api/health returns 200', async () => { test('when the database is healthy, the /api/health returns 200', async () => {
const { db } = await createInMemoryDatabase(); const { db } = await createInMemoryDatabase();
const { app } = await createServer({ db, config: overrideConfig() }); const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig() }));
const response = await app.request('/api/health'); const response = await app.request('/api/health');
@@ -28,7 +29,7 @@ describe('health check routes e2e', () => {
}, },
} as unknown as Database; } as unknown as Database;
const { app } = await createServer({ db, config: overrideConfig() }); const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig() }));
const response = await app.request('/api/health'); const response = await app.request('/api/health');

View File

@@ -2,11 +2,12 @@ import { describe, expect, test } from 'vitest';
import { overrideConfig } from '../../../config/config.test-utils'; import { overrideConfig } from '../../../config/config.test-utils';
import { createInMemoryDatabase } from '../../database/database.test-utils'; import { createInMemoryDatabase } from '../../database/database.test-utils';
import { createServer } from '../../server'; import { createServer } from '../../server';
import { createTestServerDependencies } from '../../server.test-utils';
describe('ping routes e2e', () => { describe('ping routes e2e', () => {
test('the /api/ping is a publicly accessible route that always returns a 200 with a status ok', async () => { test('the /api/ping is a publicly accessible route that always returns a 200 with a status ok', async () => {
const { db } = await createInMemoryDatabase(); const { db } = await createInMemoryDatabase();
const { app } = await createServer({ db, config: overrideConfig() }); const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig() }));
const response = await app.request('/api/ping'); const response = await app.request('/api/ping');

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from 'vitest';
import { getProcessMode } from './process.models';
describe('process models', () => {
describe('getProcessMode', () => {
test('when the process mode is set to all, the api server and worker should be enabled', () => {
expect(
getProcessMode({ config: { processMode: 'all' } }),
).to.eql({
isWebMode: true,
isWorkerMode: true,
processMode: 'all',
});
});
test('when the process mode is set to web, only the api server should be enabled', () => {
expect(
getProcessMode({ config: { processMode: 'web' } }),
).to.eql({
isWebMode: true,
isWorkerMode: false,
processMode: 'web',
});
});
test('when the process mode is set to worker, only the worker should be enabled', () => {
expect(
getProcessMode({ config: { processMode: 'worker' } }),
).to.eql({
isWebMode: false,
isWorkerMode: true,
processMode: 'worker',
});
});
});
});

View File

@@ -0,0 +1,9 @@
export function getProcessMode({ config }: { config: { processMode: 'web' | 'worker' | 'all' } }) {
const { processMode } = config;
return {
isWebMode: processMode === 'all' || processMode === 'web',
isWorkerMode: processMode === 'all' || processMode === 'worker',
processMode,
};
}

View File

@@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest';
import { overrideConfig } from '../config/config.test-utils'; import { overrideConfig } from '../config/config.test-utils';
import { createInMemoryDatabase } from './database/database.test-utils'; import { createInMemoryDatabase } from './database/database.test-utils';
import { createServer } from './server'; import { createServer } from './server';
import { createTestServerDependencies } from './server.test-utils';
function setValidParams(path: string) { function setValidParams(path: string) {
const newPath = path const newPath = path
@@ -28,7 +29,7 @@ function setValidParams(path: string) {
describe('server routes', () => { describe('server routes', () => {
test('all routes should respond with a 401 when non-authenticated, except for public and auth-related routes', async () => { test('all routes should respond with a 401 when non-authenticated, except for public and auth-related routes', async () => {
const { db } = await createInMemoryDatabase(); const { db } = await createInMemoryDatabase();
const { app } = await createServer({ db, config: overrideConfig() }); const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig() }));
const publicRoutes = [ const publicRoutes = [
'GET /api/ping', 'GET /api/ping',

View File

@@ -1,4 +1,5 @@
import type { RouteDefinitionContext } from './server.types'; import type { RouteDefinitionContext } from './server.types';
import { registerAdminRoutes } from '../admin/admin.routes';
import { registerApiKeysRoutes } from '../api-keys/api-keys.routes'; import { registerApiKeysRoutes } from '../api-keys/api-keys.routes';
import { registerConfigRoutes } from '../config/config.routes'; import { registerConfigRoutes } from '../config/config.routes';
import { registerDocumentActivityRoutes } from '../documents/document-activity/document-activity.routes'; import { registerDocumentActivityRoutes } from '../documents/document-activity/document-activity.routes';
@@ -29,4 +30,5 @@ export function registerRoutes(context: RouteDefinitionContext) {
registerWebhooksRoutes(context); registerWebhooksRoutes(context);
registerInvitationsRoutes(context); registerInvitationsRoutes(context);
registerDocumentActivityRoutes(context); registerDocumentActivityRoutes(context);
registerAdminRoutes(context);
} }

View File

@@ -0,0 +1,47 @@
import type { GlobalDependencies } from './server.types';
import { overrideConfig } from '../config/config.test-utils';
import { createDocumentSearchServices } from '../documents/document-search/document-search.registry';
import { createDocumentStorageService } from '../documents/storage/documents.storage.services';
import { createEmailsServices } from '../emails/emails.services';
import { createSubscriptionsServices } from '../subscriptions/subscriptions.services';
import { createInMemoryTaskServices } from '../tasks/tasks.test-utils';
import { createDummyTrackingServices } from '../tracking/tracking.services';
import { createAuthEmailsServices } from './auth/auth.emails.services';
import { getAuth } from './auth/auth.services';
import { setupDatabase } from './database/database';
import { registerEventHandlers } from './events/events.handlers';
import { createEventServices } from './events/events.services';
import { createGracefulShutdownService } from './graceful-shutdown/graceful-shutdown.services';
export function createTestServerDependencies(overrides: Partial<GlobalDependencies> = {}): GlobalDependencies {
const config = overrides.config ?? overrideConfig();
const shutdownServices = overrides.shutdownServices ?? createGracefulShutdownService();
const db = overrides.db ?? setupDatabase({ ...config.database, shutdownServices }).db;
const documentsStorageService = overrides.documentsStorageService ?? createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
const taskServices = overrides.taskServices ?? createInMemoryTaskServices();
const trackingServices = overrides.trackingServices ?? createDummyTrackingServices();
const eventServices = overrides.eventServices ?? createEventServices();
const emailsServices = overrides.emailsServices ?? createEmailsServices({ config });
const authEmailsServices = createAuthEmailsServices({ emailsServices });
const auth = overrides.auth ?? getAuth({ db, config, authEmailsServices, eventServices }).auth;
const subscriptionsServices = overrides.subscriptionsServices ?? createSubscriptionsServices({ config });
const documentSearchServices = overrides.documentSearchServices ?? createDocumentSearchServices({ db, config });
registerEventHandlers({ eventServices, trackingServices, db, documentSearchServices });
return {
config,
db,
shutdownServices,
documentsStorageService,
taskServices,
trackingServices,
eventServices,
emailsServices,
auth,
subscriptionsServices,
documentSearchServices,
};
}

View File

@@ -1,51 +1,17 @@
import type { GlobalDependencies, ServerInstanceGenerics } from './server.types'; import type { GlobalDependencies, ServerInstanceGenerics } from './server.types';
import { serve } from '@hono/node-server';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { secureHeaders } from 'hono/secure-headers'; import { secureHeaders } from 'hono/secure-headers';
import { createApiKeyMiddleware } from '../api-keys/api-keys.middlewares'; import { createApiKeyMiddleware } from '../api-keys/api-keys.middlewares';
import { parseConfig } from '../config/config';
import { createDocumentSearchServices } from '../documents/document-search/document-search.registry';
import { createDocumentStorageService } from '../documents/storage/documents.storage.services';
import { createEmailsServices } from '../emails/emails.services';
import { createLoggerMiddleware } from '../shared/logger/logger.middleware'; import { createLoggerMiddleware } from '../shared/logger/logger.middleware';
import { createSubscriptionsServices } from '../subscriptions/subscriptions.services';
import { createTaskServices } from '../tasks/tasks.services';
import { createTrackingServices } from '../tracking/tracking.services';
import { createAuthEmailsServices } from './auth/auth.emails.services';
import { getAuth } from './auth/auth.services';
import { setupDatabase } from './database/database';
import { createCorsMiddleware } from './middlewares/cors.middleware'; import { createCorsMiddleware } from './middlewares/cors.middleware';
import { registerErrorMiddleware } from './middlewares/errors.middleware'; import { registerErrorMiddleware } from './middlewares/errors.middleware';
import { createTimeoutMiddleware } from './middlewares/timeout.middleware'; import { createTimeoutMiddleware } from './middlewares/timeout.middleware';
import { registerRoutes } from './server.routes'; import { registerRoutes } from './server.routes';
import { registerStaticAssetsRoutes } from './static-assets/static-assets.routes'; import { registerStaticAssetsRoutes } from './static-assets/static-assets.routes';
async function createGlobalDependencies(partialDeps: Partial<GlobalDependencies>): Promise<GlobalDependencies> { export function createServer(dependencies: GlobalDependencies) {
const config = partialDeps.config ?? (await parseConfig()).config; const { config, db, shutdownServices } = dependencies;
const db = partialDeps.db ?? setupDatabase(config.database).db;
const emailsServices = createEmailsServices({ config });
const trackingServices = createTrackingServices({ config });
const auth = partialDeps.auth ?? getAuth({ db, config, authEmailsServices: createAuthEmailsServices({ emailsServices }), trackingServices }).auth;
const subscriptionsServices = createSubscriptionsServices({ config });
const taskServices = partialDeps.taskServices ?? createTaskServices({ config });
const documentsStorageService = partialDeps.documentsStorageService ?? createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
const documentSearchServices = partialDeps.documentSearchServices ?? createDocumentSearchServices({ db, config });
return {
documentsStorageService,
config,
db,
auth,
emailsServices,
subscriptionsServices,
trackingServices,
taskServices,
documentSearchServices,
};
}
export async function createServer(initialDeps: Partial<GlobalDependencies> = {}) {
const dependencies = await createGlobalDependencies(initialDeps);
const { config, trackingServices, db } = dependencies;
const app = new Hono<ServerInstanceGenerics>({ strict: true }); const app = new Hono<ServerInstanceGenerics>({ strict: true });
@@ -63,8 +29,22 @@ export async function createServer(initialDeps: Partial<GlobalDependencies> = {}
return { return {
app, app,
shutdown: async () => { start: ({ onStarted }: { onStarted?: (args: { port: number }) => void }) => {
await trackingServices.shutdown(); const server = serve(
{
fetch: app.fetch,
port: config.server.port,
hostname: config.server.hostname,
},
onStarted,
);
shutdownServices.registerShutdownHandler({
id: 'web-server-close',
handler: () => {
server.close();
},
});
}, },
}; };
} }

View File

@@ -10,6 +10,8 @@ import type { TrackingServices } from '../tracking/tracking.services';
import type { Auth } from './auth/auth.services'; import type { Auth } from './auth/auth.services';
import type { Session } from './auth/auth.types'; import type { Session } from './auth/auth.types';
import type { Database } from './database/database.types'; import type { Database } from './database/database.types';
import type { EventServices } from './events/events.services';
import type { ShutdownServices } from './graceful-shutdown/graceful-shutdown.services';
export type ServerInstanceGenerics = { export type ServerInstanceGenerics = {
Variables: { Variables: {
@@ -34,6 +36,8 @@ export type GlobalDependencies = {
taskServices: TaskServices; taskServices: TaskServices;
documentsStorageService: DocumentStorageService; documentsStorageService: DocumentStorageService;
documentSearchServices: DocumentSearchServices; documentSearchServices: DocumentSearchServices;
eventServices: EventServices;
shutdownServices: ShutdownServices;
}; };
export type RouteDefinitionContext = { app: ServerInstance } & GlobalDependencies; export type RouteDefinitionContext = { app: ServerInstance } & GlobalDependencies;

View File

@@ -1,3 +1,4 @@
import type { Context } from 'hono';
import type { Config } from '../../config/config.types'; import type { Config } from '../../config/config.types';
import type { ServerInstance } from '../server.types'; import type { ServerInstance } from '../server.types';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
@@ -26,7 +27,7 @@ export function registerStaticAssetsRoutes({ app, config }: { app: ServerInstanc
index: `unexisting-file-${Math.random().toString(36).substring(2)}`, index: `unexisting-file-${Math.random().toString(36).substring(2)}`,
}); });
return staticMiddleware(context, next); return staticMiddleware(context as Context<any, string>, next);
}, },
) )
.use( .use(

Some files were not shown because too many files have changed in this diff Show More