diff --git a/README.md b/README.md index 3c9ceb5..2faf37f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,26 @@ -# Tanstack Start - With E2E Encryption Demo +# Tanstack Start - E2E Encrypted SaaS Starter -A TanStack Start starter with Convex, Clerk, and end-to-end encryption. +> ! Not yet ready. Currently in development phase. -Full-stack React starter with zero-knowledge architecture. Sensitive data is encrypted client-side before it reaches the server; the backend never has access to decryption keys or plaintext. +A ~~production-ready~~ (soon) SaaS dashboard starter built with TanStack Start, Convex, and Clerk — with end-to-end encryption baked in from day one. + +Use this starter as the foundation for any SaaS that handles sensitive user data: note-taking apps, document editors, personal finance tools, health trackers, or anything where users deserve to know their data is private by design. + +## The Problem With Most SaaS + +Most SaaS products store user data in plaintext. This means the company (and anyone who gains access to their infrastructure) can read your users' data at any given time. + +This starter takes a different approach. Sensitive data is encrypted on the client before it ever leaves the browser. The server only stores ciphertext and it has no access to decryption keys or plaintext, ever. + +## What This Starter Provides + +A full-stack SaaS dashboard template where you can build features on top of a privacy-first foundation: + +- **Authenticated dashboard** with per-user encrypted document storage +- **Client-side encryption** — data is encrypted before upload, decrypted after download +- **Zero-knowledge key management** — keys are derived from the user's password, never sent to the server +- **Real-time sync** via Convex, without ever exposing plaintext to the backend +- **Drop-in auth** via Clerk with seamless key provisioning on sign-up/sign-in ## Tech Stack @@ -13,7 +31,9 @@ Full-stack React starter with zero-knowledge architecture. Sensitive data is enc - **UI:** Tailwind CSS, shadcn, Radix UI - **State:** Jotai -## Zero-Knowledge Architecture +## How It Works — Zero-Knowledge Architecture + +This is the foundation of a **zero-knowledge architecture**: the service provider genuinely cannot read what users store. ```mermaid sequenceDiagram diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 24f85d2..879fa09 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -8,6 +8,7 @@ * @module */ +import type * as documents from "../documents.js"; import type * as userKeys from "../userKeys.js"; import type { @@ -17,6 +18,7 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + documents: typeof documents; userKeys: typeof userKeys; }>; diff --git a/convex/documents.ts b/convex/documents.ts new file mode 100644 index 0000000..051f803 --- /dev/null +++ b/convex/documents.ts @@ -0,0 +1,57 @@ +import { v } from 'convex/values' + +import type { Id } from './_generated/dataModel' +import { mutation, query } from './_generated/server' + +/** + * Get the documents for the user + */ +export const getDocuments = query({ + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity() + if (!identity) return null + + const documents = await ctx.db + .query('documents') + .withIndex('by_user', (q) => q.eq('userId', identity.subject)) + .collect() + + return documents + }, +}) + +/** + * Get a single document by id + */ +export const getDocumentById = query({ + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity() + if (!identity) return null + + const document = await ctx.db.get('documents', args.id as Id<'documents'>) + if (!document || document.userId !== identity.subject) return null + + return document + }, +}) + +/** + * Create a new document for the user + */ +export const createDocument = mutation({ + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity() + if (!identity) throw new Error('Unauthorized') + + return await ctx.db.insert('documents', { + id: crypto.randomUUID().toString(), + userId: identity.subject, + createdAt: Date.now(), + encryptedData: args.encryptedData, + updatedAt: Date.now(), + }) + }, + args: { + encryptedData: v.string(), + }, +}) diff --git a/convex/schema.ts b/convex/schema.ts index e8f0d54..fbc486e 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -2,6 +2,23 @@ import { defineSchema, defineTable } from 'convex/server' import { v } from 'convex/values' export default defineSchema({ + /** + * Documents + * This table stores the documents for the user. + * It is used to store the encrypted data for the documents. + */ + documents: defineTable({ + id: v.string(), + userId: v.string(), + createdAt: v.number(), + encryptedData: v.string(), + updatedAt: v.number(), + }) + .index('by_user', ['userId']) + .index('id', ['id']) + .index('createdAt', ['createdAt']) + .index('updatedAt', ['updatedAt']), + /** * User keys * This table stores the user's encryption keys. diff --git a/messages/en.json b/messages/en.json index c98008b..0a2495f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3,6 +3,7 @@ "navigation": { "dashboard": "Dashboard", "encrypt": "Encryption Demo", + "documents": "Documents", "logout": "Logout" }, "sign_in": { diff --git a/messages/fr.json b/messages/fr.json index 567672b..1c3443a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3,6 +3,7 @@ "navigation": { "dashboard": "Tableau de bord", "encrypt": "Démo de chiffrement", + "documents": "Documents", "logout": "Se déconnecter" }, "sign_in": { diff --git a/src/components/Layouts/Navigation/MainNavigation.tsx b/src/components/Layouts/Navigation/MainNavigation.tsx index 6050c0a..c8c90e0 100644 --- a/src/components/Layouts/Navigation/MainNavigation.tsx +++ b/src/components/Layouts/Navigation/MainNavigation.tsx @@ -1,5 +1,9 @@ import { useNavigate } from '@tanstack/react-router' -import { LayoutDashboardIcon, MessageSquareLockIcon } from 'lucide-react' +import { + FileIcon, + LayoutDashboardIcon, + MessageSquareLockIcon, +} from 'lucide-react' import { SidebarGroup, @@ -25,10 +29,12 @@ export const MainNavigation = () => { /> } - label={m['navigation.encrypt']()} - tooltip={m['navigation.encrypt']()} - onClick={() => navigate({ to: RoutesPath.DEMO_ENCRYPT.toString() })} + icon={} + label={m['navigation.documents']()} + tooltip={m['navigation.documents']()} + onClick={() => + navigate({ to: RoutesPath.DOCUMENTS_LIST.toString() }) + } /> diff --git a/src/hooks/useEncryption.ts b/src/hooks/useEncryption.ts index b374e24..9933d05 100644 --- a/src/hooks/useEncryption.ts +++ b/src/hooks/useEncryption.ts @@ -29,5 +29,6 @@ export const useEncryption = () => { return { decryptData, encryptData, + userKeys, } } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 10dd35f..9546e32 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,18 +10,25 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' -import { Route as DemoEncryptRouteImport } from './routes/demo/encrypt' +import { Route as DocumentsListRouteImport } from './routes/documents/list' +import { Route as DocumentsCreateRouteImport } from './routes/documents/create' import { Route as AuthSignupRouteImport } from './routes/auth/signup' import { Route as AuthSigninRouteImport } from './routes/auth/signin' +import { Route as DocumentsDetailsIdRouteImport } from './routes/documents/details.$id' const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) -const DemoEncryptRoute = DemoEncryptRouteImport.update({ - id: '/demo/encrypt', - path: '/demo/encrypt', +const DocumentsListRoute = DocumentsListRouteImport.update({ + id: '/documents/list', + path: '/documents/list', + getParentRoute: () => rootRouteImport, +} as any) +const DocumentsCreateRoute = DocumentsCreateRouteImport.update({ + id: '/documents/create', + path: '/documents/create', getParentRoute: () => rootRouteImport, } as any) const AuthSignupRoute = AuthSignupRouteImport.update({ @@ -34,39 +41,71 @@ const AuthSigninRoute = AuthSigninRouteImport.update({ path: '/auth/signin', getParentRoute: () => rootRouteImport, } as any) +const DocumentsDetailsIdRoute = DocumentsDetailsIdRouteImport.update({ + id: '/documents/details/$id', + path: '/documents/details/$id', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/auth/signin': typeof AuthSigninRoute '/auth/signup': typeof AuthSignupRoute - '/demo/encrypt': typeof DemoEncryptRoute + '/documents/create': typeof DocumentsCreateRoute + '/documents/list': typeof DocumentsListRoute + '/documents/details/$id': typeof DocumentsDetailsIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/auth/signin': typeof AuthSigninRoute '/auth/signup': typeof AuthSignupRoute - '/demo/encrypt': typeof DemoEncryptRoute + '/documents/create': typeof DocumentsCreateRoute + '/documents/list': typeof DocumentsListRoute + '/documents/details/$id': typeof DocumentsDetailsIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/auth/signin': typeof AuthSigninRoute '/auth/signup': typeof AuthSignupRoute - '/demo/encrypt': typeof DemoEncryptRoute + '/documents/create': typeof DocumentsCreateRoute + '/documents/list': typeof DocumentsListRoute + '/documents/details/$id': typeof DocumentsDetailsIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/auth/signin' | '/auth/signup' | '/demo/encrypt' + fullPaths: + | '/' + | '/auth/signin' + | '/auth/signup' + | '/documents/create' + | '/documents/list' + | '/documents/details/$id' fileRoutesByTo: FileRoutesByTo - to: '/' | '/auth/signin' | '/auth/signup' | '/demo/encrypt' - id: '__root__' | '/' | '/auth/signin' | '/auth/signup' | '/demo/encrypt' + to: + | '/' + | '/auth/signin' + | '/auth/signup' + | '/documents/create' + | '/documents/list' + | '/documents/details/$id' + id: + | '__root__' + | '/' + | '/auth/signin' + | '/auth/signup' + | '/documents/create' + | '/documents/list' + | '/documents/details/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute AuthSigninRoute: typeof AuthSigninRoute AuthSignupRoute: typeof AuthSignupRoute - DemoEncryptRoute: typeof DemoEncryptRoute + DocumentsCreateRoute: typeof DocumentsCreateRoute + DocumentsListRoute: typeof DocumentsListRoute + DocumentsDetailsIdRoute: typeof DocumentsDetailsIdRoute } declare module '@tanstack/react-router' { @@ -78,11 +117,18 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/demo/encrypt': { - id: '/demo/encrypt' - path: '/demo/encrypt' - fullPath: '/demo/encrypt' - preLoaderRoute: typeof DemoEncryptRouteImport + '/documents/list': { + id: '/documents/list' + path: '/documents/list' + fullPath: '/documents/list' + preLoaderRoute: typeof DocumentsListRouteImport + parentRoute: typeof rootRouteImport + } + '/documents/create': { + id: '/documents/create' + path: '/documents/create' + fullPath: '/documents/create' + preLoaderRoute: typeof DocumentsCreateRouteImport parentRoute: typeof rootRouteImport } '/auth/signup': { @@ -99,6 +145,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthSigninRouteImport parentRoute: typeof rootRouteImport } + '/documents/details/$id': { + id: '/documents/details/$id' + path: '/documents/details/$id' + fullPath: '/documents/details/$id' + preLoaderRoute: typeof DocumentsDetailsIdRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -106,7 +159,9 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AuthSigninRoute: AuthSigninRoute, AuthSignupRoute: AuthSignupRoute, - DemoEncryptRoute: DemoEncryptRoute, + DocumentsCreateRoute: DocumentsCreateRoute, + DocumentsListRoute: DocumentsListRoute, + DocumentsDetailsIdRoute: DocumentsDetailsIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/auth/signin.tsx b/src/routes/auth/signin.tsx index 7ffca0e..7f466cb 100644 --- a/src/routes/auth/signin.tsx +++ b/src/routes/auth/signin.tsx @@ -4,7 +4,7 @@ import { useAtomValue } from 'jotai' import { passphraseAtom } from '@/stores/encryptionAtoms' import { RoutesPath } from '@/types/routes' -import { SignInView } from '@/views/SignInView' +import { SignInView } from '@/views/AuthViews/SignInView' export const Route = createFileRoute(RoutesPath.SIGN_IN)({ component: RouteComponent, diff --git a/src/routes/auth/signup.tsx b/src/routes/auth/signup.tsx index 49e54cf..04bf25a 100644 --- a/src/routes/auth/signup.tsx +++ b/src/routes/auth/signup.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { RoutesPath } from '@/types/routes' -import { SignUpView } from '@/views/SignUpView' +import { SignUpView } from '@/views/AuthViews/SignUpView' export const Route = createFileRoute(RoutesPath.SIGN_UP)({ component: RouteComponent, diff --git a/src/routes/documents/create.tsx b/src/routes/documents/create.tsx new file mode 100644 index 0000000..dfbad80 --- /dev/null +++ b/src/routes/documents/create.tsx @@ -0,0 +1,20 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { Layout } from '@/components/Layouts' +import { ProtectedRoutes } from '@/components/ProtectedRoutes' +import { RoutesPath } from '@/types/routes' +import { DocumentCreationView } from '@/views/DocumentsView' + +export const Route = createFileRoute(RoutesPath.DOCUMENTS_CREATE)({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( + + + + + + ) +} diff --git a/src/routes/documents/details.$id.tsx b/src/routes/documents/details.$id.tsx new file mode 100644 index 0000000..6707c18 --- /dev/null +++ b/src/routes/documents/details.$id.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { Layout } from '@/components/Layouts' +import { ProtectedRoutes } from '@/components/ProtectedRoutes' +import { DocumentDetailsView } from '@/views/DocumentsView' + +export const Route = createFileRoute('/documents/details/$id')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( + + + + + + ) +} diff --git a/src/routes/demo/encrypt.tsx b/src/routes/documents/list.tsx similarity index 70% rename from src/routes/demo/encrypt.tsx rename to src/routes/documents/list.tsx index daf3116..99a3b64 100644 --- a/src/routes/demo/encrypt.tsx +++ b/src/routes/documents/list.tsx @@ -1,10 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' + import { Layout } from '@/components/Layouts' import { ProtectedRoutes } from '@/components/ProtectedRoutes' import { RoutesPath } from '@/types/routes' -import { EncryptDemoView } from '@/views/EncryptDemoView' -import { createFileRoute } from '@tanstack/react-router' +import { DocumentsListView } from '@/views/DocumentsView' -export const Route = createFileRoute(RoutesPath.DEMO_ENCRYPT)({ +export const Route = createFileRoute(RoutesPath.DOCUMENTS_LIST)({ component: RouteComponent, }) @@ -12,7 +13,7 @@ function RouteComponent() { return ( - + ) diff --git a/src/types/routes.ts b/src/types/routes.ts index 0552425..d6bfea3 100644 --- a/src/types/routes.ts +++ b/src/types/routes.ts @@ -2,5 +2,7 @@ export enum RoutesPath { HOME = '/', SIGN_IN = '/auth/signin', SIGN_UP = '/auth/signup', - DEMO_ENCRYPT = '/demo/encrypt', + DOCUMENTS_CREATE = '/documents/create', + DOCUMENTS_DETAILS = '/documents/details', + DOCUMENTS_LIST = '/documents/list', } diff --git a/src/views/SignInView.tsx b/src/views/AuthViews/SignInView.tsx similarity index 100% rename from src/views/SignInView.tsx rename to src/views/AuthViews/SignInView.tsx diff --git a/src/views/SignUpView.tsx b/src/views/AuthViews/SignUpView.tsx similarity index 100% rename from src/views/SignUpView.tsx rename to src/views/AuthViews/SignUpView.tsx diff --git a/src/views/DocumentsView/DocumentCreationView.tsx b/src/views/DocumentsView/DocumentCreationView.tsx new file mode 100644 index 0000000..ba39412 --- /dev/null +++ b/src/views/DocumentsView/DocumentCreationView.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react' + +import { api } from '@convex/_generated/api' +import { useNavigate } from '@tanstack/react-router' +import { useMutation } from 'convex/react' + +import { Button } from '@/components/ui/button' +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from '@/components/ui/field' +import { Textarea } from '@/components/ui/textarea' +import { useEncryption } from '@/hooks/useEncryption' +import { RoutesPath } from '@/types/routes' + +export const DocumentCreationView = () => { + const navigate = useNavigate() + const { encryptData } = useEncryption() + const createDocument = useMutation(api.documents.createDocument) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const handleCancel = () => { + navigate({ to: RoutesPath.DOCUMENTS_LIST.toString() }) + } + + const handleCreateDocument = async ( + e: React.SubmitEvent, + ) => { + e.preventDefault() + + const formData = new FormData(e.target as HTMLFormElement) + const message = formData.get('message') as string + if (!message) throw new Error('Message is required') + + try { + setIsLoading(true) + setError(null) + const encryptedMessage = await encryptData(message) + await createDocument({ encryptedData: encryptedMessage }) + navigate({ to: RoutesPath.DOCUMENTS_LIST.toString() }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create document') + } finally { + setIsLoading(false) + } + } + + return ( +
+

Create Document

+
+ + + Message + + This message will be encrypted and stored in the database. + +