Compare commits

...

2 Commits

Author SHA1 Message Date
071ce70292 wip: test 2025-01-05 15:45:51 +11:00
866b036484 wip 2025-01-02 15:33:37 +11:00
36 changed files with 4452 additions and 1060 deletions

4
apps/remix/.dockerignore Normal file
View File

@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md

9
apps/remix/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
/node_modules/
# React Router
/.react-router/
/build/
# Vite
vite.config.ts.timestamp*

22
apps/remix/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]

25
apps/remix/Dockerfile.bun Normal file
View File

@ -0,0 +1,25 @@
FROM oven/bun:1 AS dependencies-env
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --production
FROM dependencies-env AS build-env
COPY ./package.json bun.lockb /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN bun run build
FROM dependencies-env
COPY ./package.json bun.lockb /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["bun", "run", "start"]

View File

@ -0,0 +1,26 @@
FROM node:20-alpine AS dependencies-env
RUN npm i -g pnpm
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --prod --frozen-lockfile
FROM dependencies-env AS build-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN pnpm build
FROM dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["pnpm", "start"]

100
apps/remix/README.md Normal file
View File

@ -0,0 +1,100 @@
# Welcome to React Router!
A modern, production-ready template for building full-stack React applications using React Router.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
## Features
- 🚀 Server-side rendering
- ⚡️ Hot Module Replacement (HMR)
- 📦 Asset bundling and optimization
- 🔄 Data loading and mutations
- 🔒 TypeScript by default
- 🎉 TailwindCSS for styling
- 📖 [React Router docs](https://reactrouter.com/)
## Getting Started
### Installation
Install the dependencies:
```bash
npm install
```
### Development
Start the development server with HMR:
```bash
npm run dev
```
Your application will be available at `http://localhost:5173`.
## Building for Production
Create a production build:
```bash
npm run build
```
## Deployment
### Docker Deployment
This template includes three Dockerfiles optimized for different package managers:
- `Dockerfile` - for npm
- `Dockerfile.pnpm` - for pnpm
- `Dockerfile.bun` - for bun
To build and run using Docker:
```bash
# For npm
docker build -t my-app .
# For pnpm
docker build -f Dockerfile.pnpm -t my-app .
# For bun
docker build -f Dockerfile.bun -t my-app .
# Run the container
docker run -p 3000:3000 my-app
```
The containerized application can be deployed to any platform that supports Docker, including:
- AWS ECS
- Google Cloud Run
- Azure Container Apps
- Digital Ocean App Platform
- Fly.io
- Railway
### DIY Deployment
If you're familiar with deploying Node applications, the built-in app server is production-ready.
Make sure to deploy the output of `npm run build`
```
├── package.json
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
├── build/
│ ├── client/ # Static assets
│ └── server/ # Server-side code
```
## Styling
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
---
Built with ❤️ using React Router.

1
apps/remix/app/app.css Normal file
View File

@ -0,0 +1 @@
@import '@documenso/ui/styles/theme.css';

View File

@ -0,0 +1,13 @@
import { twoFactor } from 'better-auth/plugins';
import { createAuthClient } from 'better-auth/react';
import { passkeyClientPlugin } from './auth/passkey-plugin/client';
// make sure to import from better-auth/react
export const authClient = createAuthClient({
baseURL: 'http://localhost:3000',
plugins: [twoFactor(), passkeyClientPlugin()],
});
export const { signIn, signOut, useSession } = authClient;

View File

@ -0,0 +1,112 @@
import { compare, hash } from '@node-rs/bcrypt';
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { twoFactor } from 'better-auth/plugins';
import { getAuthenticatorOptions } from '@documenso/lib/utils/authenticator';
import { prisma } from '@documenso/prisma';
import { passkeyPlugin } from './auth/passkey-plugin';
// todo: import from @documenso/lib/constants/auth
export const SALT_ROUNDS = 12;
const passkeyOptions = getAuthenticatorOptions();
export const auth = betterAuth({
appName: 'Documenso',
plugins: [
twoFactor({
issuer: 'Documenso',
skipVerificationOnEnable: true,
// totpOptions: {
// },
schema: {
twoFactor: {
modelName: 'TwoFactor',
fields: {
userId: 'userId',
secret: 'secret',
backupCodes: 'backupCodes',
},
},
},
// todo: add options
}),
passkeyPlugin(),
// passkey({
// rpID: passkeyOptions.rpId,
// rpName: passkeyOptions.rpName,
// origin: passkeyOptions.origin,
// schema: {
// passkey: {
// fields: {
// publicKey: 'credentialPublicKey',
// credentialID: 'credentialId',
// deviceType: 'credentialDeviceType',
// backedUp: 'credentialBackedUp',
// // transports: '',
// },
// },
// },
// }),
],
secret: 'secret', // todo
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
databaseHooks: {
account: {
create: {
before: (session) => {
return {
data: {
...session,
accountId: session.accountId.toString(),
},
};
},
},
},
},
session: {
fields: {
token: 'sessionToken',
expiresAt: 'expires',
},
},
user: {
fields: {
emailVerified: 'isEmailVerified',
},
},
account: {
fields: {
providerId: 'provider',
accountId: 'providerAccountId',
refreshToken: 'refresh_token',
accessToken: 'access_token',
idToken: 'id_token',
},
},
advanced: {
generateId: false,
},
socialProviders: {
google: {
clientId: '',
clientSecret: '',
},
},
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
// maxPasswordLength: 128,
// minPasswordLength: 8,
password: {
hash: async (password) => hash(password, SALT_ROUNDS),
verify: async ({ hash, password }) => compare(password, hash),
},
},
});

View File

@ -0,0 +1,24 @@
import type { BetterAuthClientPlugin } from 'better-auth';
import type { passkeyPlugin } from './index';
type PasskeyPlugin = typeof passkeyPlugin;
export const passkeyClientPlugin = () => {
const passkeySignin = () => {
//
// credential: JSON.stringify(credential),
// callbackUrl,
};
return {
id: 'passkeyPlugin',
getActions: () => ({
signIn: {
passkey: () => passkeySignin,
},
}),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
$InferServerPlugin: {} as ReturnType<PasskeyPlugin>,
} satisfies BetterAuthClientPlugin;
};

View File

@ -0,0 +1,165 @@
import type { BetterAuthPlugin } from 'better-auth';
import { createAuthEndpoint, createAuthMiddleware } from 'better-auth/plugins';
export const passkeyPlugin = () =>
({
id: 'passkeyPlugin',
schema: {
user: {
fields: {
// twoFactorEnabled: {
// type: 'boolean',
// required: false,
// },
// twoFactorBackupCodes: {
// type: 'string',
// required: false,
// },
// twoFactorSecret: {
// type: 'string',
// required: false,
// },
// birthday: {
// type: 'date', // string, number, boolean, date
// required: true, // if the field should be required on a new record. (default: false)
// unique: false, // if the field should be unique. (default: false)
// reference: null, // if the field is a reference to another table. (default: null)
// },
},
},
},
endpoints: {
authorize: createAuthEndpoint(
'/passkey/authorize',
{
method: 'POST',
// use: [],
},
async (ctx) => {
const csrfToken = credentials?.csrfToken;
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
let requestBodyCrediential: TAuthenticationResponseJSONSchema | null = null;
try {
const parsedBodyCredential = JSON.parse(req.body?.credential);
requestBodyCrediential = ZAuthenticationResponseJSONSchema.parse(parsedBodyCredential);
} catch {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
const challengeToken = await prisma.anonymousVerificationToken
.delete({
where: {
id: csrfToken,
},
})
.catch(() => null);
if (!challengeToken) {
return null;
}
if (challengeToken.expiresAt < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE);
}
const passkey = await prisma.passkey.findFirst({
where: {
credentialId: Buffer.from(requestBodyCrediential.id, 'base64'),
},
include: {
User: {
select: {
id: true,
email: true,
name: true,
emailVerified: true,
},
},
},
});
if (!passkey) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const user = passkey.User;
const { rpId, origin } = getAuthenticatorOptions();
const verification = await verifyAuthenticationResponse({
response: requestBodyCrediential,
expectedChallenge: challengeToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null);
const requestMetadata = extractNextAuthRequestMetadata(req);
if (!verification?.verified) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
},
});
return null;
}
await prisma.passkey.update({
where: {
id: passkey.id,
},
data: {
lastUsedAt: new Date(),
counter: verification.authenticationInfo.newCounter,
},
});
return {
id: Number(user.id),
email: user.email,
name: user.name,
emailVerified: user.emailVerified?.toISOString() ?? null,
} satisfies User;
},
),
},
hooks: {
before: [
{
matcher: (context) => context.path.startsWith('/sign-in/email'),
handler: createAuthMiddleware(async (ctx) => {
console.log('here...');
const { birthday } = ctx.body;
if ((!birthday) instanceof Date) {
throw new APIError('BAD_REQUEST', { message: 'Birthday must be of type Date.' });
}
const today = new Date();
const fiveYearsAgo = new Date(today.setFullYear(today.getFullYear() - 5));
if (birthday >= fiveYearsAgo) {
throw new APIError('BAD_REQUEST', { message: 'User must be above 5 years old.' });
}
return { context: ctx };
}),
},
],
},
}) satisfies BetterAuthPlugin;

74
apps/remix/app/root.tsx Normal file
View File

@ -0,0 +1,74 @@
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
} from 'react-router';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
export const links: Route.LinksFunction = () => [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
},
{ rel: 'stylesheet', href: stylesheet },
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = 'Oops!';
let details = 'An unexpected error occurred.';
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Error';
details =
error.status === 404 ? 'The requested page could not be found.' : error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="container mx-auto p-4 pt-16">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full overflow-x-auto p-4">
<code>{stack}</code>
</pre>
)}
</main>
);
}

4
apps/remix/app/routes.ts Normal file
View File

@ -0,0 +1,4 @@
import { type RouteConfig } from '@react-router/dev/routes';
import { flatRoutes } from '@react-router/fs-routes';
export default flatRoutes() satisfies RouteConfig;

View File

@ -0,0 +1,193 @@
import { useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { authClient, signOut, useSession } from '~/lib/auth-client';
import { auth } from '~/lib/auth.server';
import type { Route } from '../+types/root';
export function meta({}: Route.MetaArgs) {
return [
{ title: 'New React Router App' },
{ name: 'description', content: 'Welcome to React Router!' },
];
}
export async function loader({ params, request, context }: Route.LoaderArgs) {
const session = await auth.api.getSession({
query: {
disableCookieCache: true,
},
headers: request.headers, // pass the headers
});
return {
session,
};
}
export function clientLoader({ params }: Route.ClientLoaderArgs) {
return {
session: authClient.getSession(),
};
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { data } = useSession();
const [email, setEmail] = useState('deepfriedcoconut@gmail.com');
const [password, setPassword] = useState('password');
const signIn = async () => {
await authClient.signIn.email(
{
email,
password,
},
{
onRequest: (ctx) => {
// show loading state
},
onSuccess: (ctx) => {
console.log('success');
// redirect to home
},
onError: (ctx) => {
console.log(ctx.error);
alert(ctx.error);
},
},
);
};
const signUp = async () => {
await authClient.signUp.email(
{
email,
password,
name: '',
},
{
onRequest: (ctx) => {
// show loading state
},
onSuccess: (ctx) => {
console.log(ctx);
// redirect to home
},
onError: (ctx) => {
console.log(ctx.error);
alert(ctx.error);
},
},
);
};
return (
<main className="flex flex-col items-center justify-center pb-4 pt-16">
<h1>Status: {data ? 'Authenticated' : 'Not Authenticated'}</h1>
{data ? (
<>
<div>
<p>Session data</p>
<p className="mt-2 max-w-2xl text-xs">{JSON.stringify(data, null, 2)}</p>
</div>
<div className="space-x-2">
<Button
onClick={() => {
authClient.twoFactor
.enable({
password: 'password', // user password required
})
.catch((e) => {
console.log(e);
});
}}
>
Enable 2FA
</Button>
<Button
variant="destructive"
onClick={() => {
authClient.twoFactor.disable({
password: 'password',
});
}}
>
Disable 2FA
</Button>
</div>
<button onClick={() => signOut()}>signout</button>
</>
) : (
<>
<div className="">
<h2>Sign In</h2>
<input
className="border border-blue-500"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
className="border border-blue-500"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit" onClick={signIn}>
Sign In
</button>
</div>
<div className="mt-8">
<h2>Sign Up</h2>
<input
type="email"
className="border border-blue-500"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
className="border border-blue-500"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit" onClick={signUp}>
Sign Up
</button>
</div>
<button
onClick={() => {
authClient.signIn.social({
provider: 'google',
});
}}
>
google
</button>
<button
onClick={async () => {
const response = await authClient.signIn.passkey();
console.log(response);
}}
>
passkey
</button>
</>
)}
</main>
);
}

View File

@ -0,0 +1,12 @@
// Adjust the path as necessary
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import { auth } from '~/lib/auth.server';
export function loader({ request }: LoaderFunctionArgs) {
return auth.handler(request);
}
export function action({ request }: ActionFunctionArgs) {
return auth.handler(request);
}

View File

@ -0,0 +1,40 @@
import { useState } from 'react';
import { authClient } from '~/lib/auth-client';
export default function SignIn() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const signIn = async () => {
await authClient.signIn.email(
{
email,
password,
},
{
onRequest: (ctx) => {
// show loading state
},
onSuccess: (ctx) => {
// redirect to home
},
onError: (ctx) => {
alert(ctx.error);
},
},
);
};
return (
<div>
<h2>Sign In</h2>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit" onClick={signIn}>
Sign In
</button>
</div>
);
}

43
apps/remix/package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "@documenso/remix",
"type": "module",
"private": true,
"scripts": {
"build": "cross-env NODE_ENV=production react-router build",
"dev": "tsx watch --ignore \"vite.config.ts*\" server/main.ts",
"start": "cross-env NODE_ENV=production node dist/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",
"@documenso/ee": "*",
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@epic-web/remember": "^1.1.0",
"@hono/node-server": "^1.13.7",
"@react-router/fs-routes": "^7.1.1",
"@react-router/node": "^7.1.1",
"@react-router/serve": "^7.1.1",
"better-auth": "^1.1.9",
"hono": "^4.6.15",
"isbot": "^5.1.17",
"react": "^18",
"react-dom": "^18",
"react-router": "^7.1.1",
"remix-hono": "^0.0.18"
},
"devDependencies": {
"@react-router/dev": "^7.1.1",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"cross-env": "^7.0.3",
"tsx": "^4.11.0",
"typescript": "5.7.2",
"vite": "^5.4.11",
"vite-tsconfig-paths": "^5.1.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,7 @@
import type { Config } from '@react-router/dev/config';
export default {
appDirectory: 'app',
// Server-side render by default, to enable SPA mode set this to `false`
ssr: true,
} satisfies Config;

45
apps/remix/server/app.ts Normal file
View File

@ -0,0 +1,45 @@
import { remember } from '@epic-web/remember';
import { type HttpBindings } from '@hono/node-server';
import { Hono } from 'hono';
import { reactRouter } from 'remix-hono/handler';
type Bindings = HttpBindings;
const app = new Hono<{ Bindings: Bindings }>();
const isProduction = process.env.NODE_ENV === 'production';
const viteDevServer = isProduction
? undefined
: await import('vite').then(async (vite) =>
vite.createServer({
server: { middlewareMode: true },
}),
);
const reactRouterMiddleware = remember('reactRouterMiddleware', async () =>
reactRouter({
mode: isProduction ? 'production' : 'development',
build: isProduction
? // @ts-expect-error build/server/index.js is a build artifact
await import('../build/server/index.js')
: async () => viteDevServer!.ssrLoadModule('virtual:react-router/server-build'),
}),
);
// app.get('/', (c) => c.text('Hello, world!'));
if (viteDevServer) {
app.use('*', async (c, next) => {
return new Promise((resolve) => {
viteDevServer.middlewares(c.env.incoming, c.env.outgoing, () => resolve(next()));
});
});
}
app.use('*', async (c, next) => {
const middleware = await reactRouterMiddleware;
return middleware(c, next);
});
export default app;

View File

@ -0,0 +1,7 @@
import { serve } from '@hono/node-server';
import app from './app';
serve(app, (info) => {
console.log(`Server is running on http://localhost:${info.port}`);
});

View File

@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const baseConfig = require('@documenso/tailwind-config');
const path = require('path');
module.exports = {
...baseConfig,
content: [
...baseConfig.content,
'./app/**/*.{ts,tsx}',
`${path.join(require.resolve('@documenso/ui'), '..')}/components/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/ui'), '..')}/icons/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/ui'), '..')}/lib/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/ui'), '..')}/primitives/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/email'), '..')}/templates/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/email'), '..')}/template-components/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/email'), '..')}/providers/**/*.{ts,tsx}`,
],
};

27
apps/remix/tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"include": [
"**/*",
"**/.server/**/*",
"**/.client/**/*",
".react-router/types/**/*"
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
}
}

17
apps/remix/vite.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import tailwindcss from 'tailwindcss';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
css: {
postcss: {
plugins: [tailwindcss, autoprefixer],
},
},
plugins: [reactRouter(), tsconfigPaths()],
optimizeDeps: {
exclude: ['@node-rs/bcrypt'],
},
});

View File

@ -73,6 +73,6 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/ua-parser-js": "^0.7.39",
"typescript": "5.2.2"
"typescript": "5.7.2"
}
}

4319
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",
"dev": "turbo run dev --filter=@documenso/web",
"dev:remix": "turbo run dev --filter=@documenso/remix",
"dev:web": "turbo run dev --filter=@documenso/web",
"dev:docs": "turbo run dev --filter=@documenso/documentation",
"dev:openpage-api": "turbo run dev --filter=@documenso/openpage-api",
@ -71,6 +72,7 @@
"mupdf": "^1.0.0",
"next-runtime-env": "^3.2.0",
"react": "^18",
"typescript": "5.7.2",
"zod": "3.24.1"
},
"overrides": {

View File

@ -15,6 +15,6 @@
"eslint-plugin-package-json": "^0.10.4",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-unused-imports": "^3.1.0",
"typescript": "5.2.2"
"typescript": "5.7.2"
}
}

View File

@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "secondaryId" TEXT;
-- Set all null secondaryId fields to a uuid
UPDATE "User" SET "secondaryId" = gen_random_uuid()::text WHERE "secondaryId" IS NULL;
-- Restrict the User to required
ALTER TABLE "User" ALTER COLUMN "secondaryId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "User_secondaryId_key" ON "User"("secondaryId");

View File

@ -0,0 +1,36 @@
/*
Warnings:
- Added the required column `updatedAt` to the `Account` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Session` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "accessTokenExpiresAt" TIMESTAMP(3),
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "password" TEXT,
ADD COLUMN "refreshTokenExpiresAt" TIMESTAMP(3),
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
ALTER COLUMN "type" SET DEFAULT 'legacy';
-- AlterTable
ALTER TABLE "Session" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "ipAddress" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "userAgent" TEXT;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "image" TEXT,
ADD COLUMN "isEmailVerified" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3),
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);

View File

@ -0,0 +1,28 @@
-- Migrate DOCUMENSO users to have proper Account records
DO $$
BEGIN
INSERT INTO "Account" (
"id",
"userId",
"type",
"provider",
"providerAccountId",
"password",
"createdAt",
"updatedAt"
)
SELECT
gen_random_uuid()::text,
u.id,
'legacy',
'credential',
u.email,
u.password,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "User" u
LEFT JOIN "Account" a ON a."userId" = u.id AND a."provider" = 'documenso'
WHERE
u."identityProvider" = 'DOCUMENSO'
AND a.id IS NULL;
END $$;

View File

@ -0,0 +1,32 @@
-- CreateTable
CREATE TABLE "TwoFactor" (
"id" TEXT NOT NULL,
"secret" TEXT NOT NULL,
"backupCodes" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "TwoFactor_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "TwoFactor" ADD CONSTRAINT "TwoFactor_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
DO $$
BEGIN
-- Then migrate two factor data
INSERT INTO "TwoFactor" (
"secret",
"backupCodes",
"userId"
)
SELECT
u."twoFactorSecret",
COALESCE(u."twoFactorBackupCodes", ''),
u.id
FROM "User" u
LEFT JOIN "TwoFactor" tf ON tf."userId" = u.id
WHERE
u."twoFactorSecret" IS NOT NULL
AND u."twoFactorEnabled" = true
AND tf.id IS NULL;
END $$;

View File

@ -32,7 +32,7 @@
"dotenv-cli": "^7.3.0",
"prisma-kysely": "^1.8.0",
"tsx": "^4.11.0",
"typescript": "5.2.2",
"typescript": "5.7.2",
"zod-prisma-types": "^3.1.8"
}
}

View File

@ -29,10 +29,12 @@ enum Role {
model User {
id Int @id @default(autoincrement())
secondaryId String @unique @default(cuid())
name String?
customerId String? @unique
email String @unique
emailVerified DateTime?
isEmailVerified Boolean @default(false)
password String?
source String?
signature String?
@ -52,9 +54,13 @@ model User {
ownedTeams Team[]
ownedPendingTeams TeamPending[]
teamMembers TeamMember[]
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
// Todo: Delete these after full auth migration.
twoFactorBackupCodes String?
twoFactorSecret String?
// End of Todo.
url String? @unique
profile UserProfile?
@ -67,9 +73,21 @@ model User {
passkeys Passkey[]
avatarImage AvatarImage? @relation(fields: [avatarImageId], references: [id], onDelete: SetNull)
image String?
twofactors TwoFactor[]
@@index([email])
}
model TwoFactor {
id String @id @default(cuid())
secret String
backupCodes String
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model UserProfile {
id String @id @default(cuid())
enabled Boolean @default(false)
@ -248,7 +266,6 @@ model Subscription {
model Account {
id String @id @default(cuid())
userId Int
type String
provider String
providerAccountId String
refresh_token String? @db.Text
@ -256,12 +273,24 @@ model Account {
expires_at Int?
// Some providers return created_at so we need to make it optional
created_at Int?
// Stops next-auth from crashing when dealing with AzureAD
ext_expires_in Int?
token_type String?
scope String?
id_token String? @db.Text
// Betterauth
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
password String?
// Stops next-auth from crashing when dealing with AzureAD
ext_expires_in Int?
// Todo: Remove these fields after auth migration.
type String @default("legacy")
token_type String?
session_state String?
// End of Todo.
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@ -274,6 +303,23 @@ model Session {
userId Int
expires DateTime
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
// Better auth fields.
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
}
model Verification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@map("verification")
}
enum DocumentStatus {

View File

@ -18,6 +18,6 @@
"ts-pattern": "^5.0.5"
},
"devDependencies": {
"vitest": "^1.3.1"
"vitest": "^2.1.8"
}
}

View File

@ -23,7 +23,7 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"react": "^18",
"typescript": "5.2.2"
"typescript": "5.7.2"
},
"dependencies": {
"@documenso/lib": "*",