├── .env.example ├── .gitignore ├── .npmrc ├── README.md ├── components.json ├── drizzle.config.central.ts ├── drizzle.config.tenant.ts ├── drizzle ├── central │ ├── 0000_pink_yellowjacket.sql │ └── meta │ │ ├── 0000_snapshot.json │ │ └── _journal.json └── tenant │ ├── 0000_modern_sir_ram.sql │ └── meta │ ├── 0000_snapshot.json │ └── _journal.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── src ├── app.css ├── app.d.ts ├── app.html ├── hooks.server.ts ├── hooks.ts ├── lib │ ├── client │ │ ├── components │ │ │ └── ui │ │ │ │ ├── avatar │ │ │ │ ├── avatar-fallback.svelte │ │ │ │ ├── avatar-image.svelte │ │ │ │ ├── avatar.svelte │ │ │ │ └── index.ts │ │ │ │ ├── badge │ │ │ │ ├── badge.svelte │ │ │ │ └── index.ts │ │ │ │ ├── button │ │ │ │ ├── button.svelte │ │ │ │ └── index.ts │ │ │ │ ├── card │ │ │ │ ├── card-content.svelte │ │ │ │ ├── card-description.svelte │ │ │ │ ├── card-footer.svelte │ │ │ │ ├── card-header.svelte │ │ │ │ ├── card-title.svelte │ │ │ │ ├── card.svelte │ │ │ │ └── index.ts │ │ │ │ ├── checkbox │ │ │ │ ├── checkbox.svelte │ │ │ │ └── index.ts │ │ │ │ ├── command │ │ │ │ ├── command-dialog.svelte │ │ │ │ ├── command-empty.svelte │ │ │ │ ├── command-group.svelte │ │ │ │ ├── command-input.svelte │ │ │ │ ├── command-item.svelte │ │ │ │ ├── command-list.svelte │ │ │ │ ├── command-separator.svelte │ │ │ │ ├── command-shortcut.svelte │ │ │ │ ├── command.svelte │ │ │ │ └── index.ts │ │ │ │ ├── dialog │ │ │ │ ├── dialog-content.svelte │ │ │ │ ├── dialog-description.svelte │ │ │ │ ├── dialog-footer.svelte │ │ │ │ ├── dialog-header.svelte │ │ │ │ ├── dialog-overlay.svelte │ │ │ │ ├── dialog-portal.svelte │ │ │ │ ├── dialog-title.svelte │ │ │ │ └── index.ts │ │ │ │ ├── dropdown-menu │ │ │ │ ├── dropdown-menu-checkbox-item.svelte │ │ │ │ ├── dropdown-menu-content.svelte │ │ │ │ ├── dropdown-menu-item.svelte │ │ │ │ ├── dropdown-menu-label.svelte │ │ │ │ ├── dropdown-menu-radio-group.svelte │ │ │ │ ├── dropdown-menu-radio-item.svelte │ │ │ │ ├── dropdown-menu-separator.svelte │ │ │ │ ├── dropdown-menu-shortcut.svelte │ │ │ │ ├── dropdown-menu-sub-content.svelte │ │ │ │ ├── dropdown-menu-sub-trigger.svelte │ │ │ │ └── index.ts │ │ │ │ ├── form │ │ │ │ ├── form-button.svelte │ │ │ │ ├── form-description.svelte │ │ │ │ ├── form-element-field.svelte │ │ │ │ ├── form-field-errors.svelte │ │ │ │ ├── form-field.svelte │ │ │ │ ├── form-fieldset.svelte │ │ │ │ ├── form-label.svelte │ │ │ │ ├── form-legend.svelte │ │ │ │ └── index.ts │ │ │ │ ├── input │ │ │ │ ├── index.ts │ │ │ │ └── input.svelte │ │ │ │ ├── label │ │ │ │ ├── index.ts │ │ │ │ └── label.svelte │ │ │ │ ├── popover │ │ │ │ ├── index.ts │ │ │ │ └── popover-content.svelte │ │ │ │ ├── radio-group │ │ │ │ ├── index.ts │ │ │ │ ├── radio-group-item.svelte │ │ │ │ └── radio-group.svelte │ │ │ │ ├── select │ │ │ │ ├── index.ts │ │ │ │ ├── select-content.svelte │ │ │ │ ├── select-item.svelte │ │ │ │ ├── select-label.svelte │ │ │ │ ├── select-separator.svelte │ │ │ │ └── select-trigger.svelte │ │ │ │ ├── separator │ │ │ │ ├── index.ts │ │ │ │ └── separator.svelte │ │ │ │ ├── sheet │ │ │ │ ├── index.ts │ │ │ │ ├── sheet-content.svelte │ │ │ │ ├── sheet-description.svelte │ │ │ │ ├── sheet-footer.svelte │ │ │ │ ├── sheet-header.svelte │ │ │ │ ├── sheet-overlay.svelte │ │ │ │ ├── sheet-portal.svelte │ │ │ │ └── sheet-title.svelte │ │ │ │ └── table │ │ │ │ ├── index.ts │ │ │ │ ├── table-body.svelte │ │ │ │ ├── table-caption.svelte │ │ │ │ ├── table-cell.svelte │ │ │ │ ├── table-footer.svelte │ │ │ │ ├── table-head.svelte │ │ │ │ ├── table-header.svelte │ │ │ │ ├── table-row.svelte │ │ │ │ └── table.svelte │ │ └── shadcn-utils.ts │ ├── server │ │ ├── auth.ts │ │ ├── db │ │ │ ├── central │ │ │ │ ├── index.ts │ │ │ │ ├── relations.ts │ │ │ │ └── schema.ts │ │ │ └── tenant │ │ │ │ ├── index.ts │ │ │ │ ├── relations.ts │ │ │ │ └── schema.ts │ │ └── utils │ │ │ ├── fluff.ts │ │ │ ├── getTenantInformation.ts │ │ │ ├── init-db.ts │ │ │ └── password-utils.ts │ └── util.ts └── routes │ ├── (central) │ ├── +layout.svelte │ ├── +page.server.ts │ ├── +page.svelte │ └── schema.ts │ ├── (tenant) │ └── tenant │ │ └── [subdomain] │ │ ├── (authenticated) │ │ ├── +layout.server.ts │ │ ├── +layout.svelte │ │ ├── +page.server.ts │ │ ├── logout │ │ │ └── +server.ts │ │ ├── settings │ │ │ ├── +layout.server.ts │ │ │ ├── +layout.svelte │ │ │ ├── +page.server.ts │ │ │ ├── data │ │ │ │ ├── +page.svelte │ │ │ │ └── download-dump │ │ │ │ │ └── +server.ts │ │ │ ├── domains │ │ │ │ ├── +page.server.ts │ │ │ │ ├── +page.svelte │ │ │ │ └── schema.ts │ │ │ ├── general │ │ │ │ ├── +page.server.ts │ │ │ │ ├── +page.svelte │ │ │ │ └── schema.ts │ │ │ └── users │ │ │ │ ├── +page.server.ts │ │ │ │ ├── +page.svelte │ │ │ │ └── schema.ts │ │ └── tasks │ │ │ ├── (components) │ │ │ ├── data-table-checkbox.svelte │ │ │ ├── data-table-column-header.svelte │ │ │ ├── data-table-create-task.svelte │ │ │ ├── data-table-date-cell.svelte │ │ │ ├── data-table-faceted-filter.svelte │ │ │ ├── data-table-pagination.svelte │ │ │ ├── data-table-priority-cell.svelte │ │ │ ├── data-table-row-actions.svelte │ │ │ ├── data-table-status-cell.svelte │ │ │ ├── data-table-title-cell.svelte │ │ │ ├── data-table-toolbar.svelte │ │ │ ├── data-table-view-options.svelte │ │ │ ├── data-table.svelte │ │ │ ├── index.ts │ │ │ └── user-nav.svelte │ │ │ ├── (data) │ │ │ ├── data.ts │ │ │ └── schemas.ts │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── tasksStore.ts │ │ ├── (guest) │ │ └── login │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── schema.ts │ │ ├── +layout.server.ts │ │ └── +layout.svelte │ └── +layout.svelte ├── static └── favicon.png ├── svelte.config.js ├── tailwind.config.ts ├── tsconfig.json ├── vite.config.ts └── wrangler.toml /.env.example: -------------------------------------------------------------------------------- 1 | TURSO_PLATFORM_AUTH_TOKEN= 2 | TURSO_GROUP_AUTH_TOKEN= 3 | TURSO_CENTRAL_DATABASE_URL=libsql://-.turso.io 4 | TURSO_SCHEMA_DATABASE_NAME= 5 | TURSO_ORGANIZATION_NAME= 6 | TURSO_GROUP_NAME=default 7 | 8 | CLOUDFLARE_ZONE_ID= 9 | CLOUDFLARE_TOKEN= 10 | 11 | PUBLIC_DOMAIN=localhost:5173 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Output 4 | .output 5 | .vercel 6 | /.svelte-kit 7 | /build 8 | .cloudflare 9 | 10 | # OS 11 | .DS_Store 12 | Thumbs.db 13 | 14 | # Env 15 | .env 16 | .env.* 17 | !.env.example 18 | !.env.test 19 | 20 | # Vite 21 | vite.config.js.timestamp-* 22 | vite.config.ts.timestamp-* 23 | 24 | # wrangler files 25 | .wrangler 26 | .dev.vars 27 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Multi-Tenant SvelteKit Web App 2 | 3 | This project is a SaaS web application built with SvelteKit, showcasing a multi-tenancy implementation using Turso for database management and Cloudflare for hosting and domain management. 4 | 5 | Demo : [https://miloudi-mutli-tenancy.software/](https://miloudi-mutli-tenancy.software/) 6 | 7 | ## Features 8 | 9 | - Multi-tenancy with one database per tenant using [Turso Multi-DB Schemas](https://docs.turso.tech/features/multi-db-schemas) 10 | - Subdomain per tenant, achieved by wildcard subdomain and [SvelteKit hooks](https://kit.svelte.dev/docs/hooks#universal-hooks-reroute) 11 | - Custom tenant domain with auto SSL using [Cloudflare for SaaS](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/) 12 | - Tenants can download their entire database dump. [Reference](https://docs.turso.tech/sdk/http/reference#get-dump) 13 | - Deployed on [Cloudflare Workers](https://developers.cloudflare.com/workers/) 14 | 15 | ## Prerequisites 16 | 17 | Before you begin, ensure you have the following: 18 | 19 | - Node.js and pnpm installed. 20 | - Turso CLI installed. [Docs](https://docs.turso.tech/cli/installation). 21 | - An apex domain connected to Cloudflare. 22 | 23 | ## Environment Variables 24 | 25 | For development copy `.env.example` to `.env`, For build and production, export the environment variables to the build environment. If that's Cloudflare Workers, add them to build environment. 26 | 27 | ``` 28 | TURSO_PLATFORM_AUTH_TOKEN= 29 | TURSO_GROUP_AUTH_TOKEN= 30 | TURSO_CENTRAL_DATABASE_URL=libsql://-.turso.io 31 | TURSO_SCHEMA_DATABASE_NAME= 32 | TURSO_ORGANIZATION_NAME= 33 | TURSO_GROUP_NAME=default 34 | CLOUDFLARE_ZONE_ID= 35 | CLOUDFLARE_TOKEN= 36 | PUBLIC_DOMAIN=localhost:5173 37 | ``` 38 | 39 | ### How to obtain the environment variables: 40 | 41 | 1. `TURSO_PLATFORM_AUTH_TOKEN`: Run `turso auth api-tokens mint `. This token allows you to create new databases for tenants. 42 | 2. `TURSO_GROUP_AUTH_TOKEN`: Run `turso group tokens create`. This token allows you to access all the databases in `TURSO_GROUP_NAME`. 43 | 3. `TURSO_CENTRAL_DATABASE_URL`: Create a central database in Turso and use the provided URL, Command `turso db create `. This database manages all the tenants including their domains and subdomains. This is where you would track usage for subscription and allow/disallow/limit features if this were a SaaS. 44 | 4. `TURSO_SCHEMA_DATABASE_NAME`: Create a database with the schema flag in Turso, Command : `turso db create --type schema`.This database will not have any rows, but it's used to synchronize the tenants db. [docs](https://docs.turso.tech/features/multi-db-schemas) 45 | 5. `TURSO_ORGANIZATION_NAME`: Run `turso org list` and use the slug of your organization. 46 | 6. `TURSO_GROUP_NAME`: Default is "default". 47 | 7. `CLOUDFLARE_ZONE_ID`: After having added your domain to cloudflare. Go to Cloudflare Dashboard -> Websites -> (your domain) -> you can find the Zone ID in the right sidebar. 48 | 8. `CLOUDFLARE_TOKEN`: Generate a token with access to your zone with permission `SSL and Certificates:Edit`. This is used to manage custom tenant domains. 49 | 9. `PUBLIC_DOMAIN`: Use `localhost:5173` for local development, change to your domain in production. Note: If you don't put the right _PUBLIC_DOMAIN_ you won't be able to access your app normally because the right domain will be treated as a custom tenant domain which probably does not exist. 50 | 51 | ## Installation 52 | 53 | 1. Clone the repository: 54 | 55 | ```bash 56 | git clone https://github.com/ricin9/sveltekit-multi-tenancy 57 | ``` 58 | 59 | 2. Navigate to the project directory: 60 | 61 | ```bash 62 | cd sveltekit-multi-tenancy 63 | ``` 64 | 65 | 3. Install dependencies: 66 | 67 | ```bash 68 | pnpm install 69 | ``` 70 | 71 | 4. Set up your environment variables as described above. 72 | 73 | 5. Migrate your central and schema databases 74 | 75 | ```bash 76 | pnpm db:central migrate 77 | pnpm db:tenant migrate 78 | ``` 79 | 80 | 6. Run the development server: 81 | 82 | ```bash 83 | pnpm run dev 84 | ``` 85 | 86 | ## Deployment to Cloudflare Workers 87 | 88 | To deploy your SvelteKit app to Cloudflare Workers, follow these steps: 89 | 90 | 1. Create a Cloudflare Workers project: 91 | 92 | - Log in to your Cloudflare dashboard 93 | - Navigate to "Workers & Pages" 94 | - Click "Create application" and follow the prompts to set up your project 95 | 96 | 2. Add environment variables to build environment: 97 | 98 | - In your Cloudflare Workers project settings, go to the "Settings" tab 99 | - Scroll down to "Environment Variables" 100 | - Add all the environment variables from your `.env` file 101 | - Note: Do not add environment variables to production or preview environment, instead add them to Build environment. This step is crucial because we're using Sveltekit static env which embeds envars as strings directly in build step. 102 | 103 | 3. Modify Worker routes: 104 | Assuming your domain is `example.com`, add the following routes: 105 | 106 | - `example.com` 107 | - `*.example.com` 108 | - `*/*` (This route is for custom domains added through Cloudflare for SaaS) 109 | 110 | To add these routes: 111 | 112 | - Go to your Cloudflare dashboard -> Workers -> Your Worker -> Settings -> Domains & Routes 113 | - For the first two domains select _Custom Domain_, for `*/*` select _Routes_, for the zone select your `PUBLIC_DOMAIN`. 114 | 115 | 4. Enable Custom Hostname SSL: 116 | 117 | - In your Cloudflare site's SSL/TLS settings, go to the "Custom Hostnames" tab 118 | - Ensure that "Cloudflare For SaaS" is enabled. [Docs](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/start/enable/) 119 | 120 | 5. Deploy your application: 121 | 122 | 1. Manual Deployment. 123 | 124 | - Run `wrangler deploy`. 125 | 126 | 2. Automatic Deployment. 127 | 128 | - If you're using Worker Git integration (added very recently as of writing of this readme), just push to your remote git repo and it will be pulled down and built automatically. 129 | 130 | Remember to update your `PUBLIC_DOMAIN` environment variable to your production domain when deploying. 131 | 132 | ## Why Cloudflare Workers and not Pages 133 | 134 | As of the date of writing this readme, there is no way to add wildcard subdomain to pages `*.example.com`, and you cannot add `*/*` route so that custom tenant domains are routes automatically to your worker. 135 | 136 | You can use Cloudflare Pages, but you would have to add each created subdomain and custom tenant domain either manually or by using the Cloudflare API. I prefer automatically for this demo this is why I used Cloudflare Workers instead. 137 | 138 | ## How are SSL certificates installed for custom tenant domains 139 | 140 | There are two methods: HTTP and TXT, ([Source](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/hostname-validation/pre-validation/)), HTTP is the fastest one as it only required the tenant to point their domain towards your `PUBLIC_DOMAIN` with CNAME dns record after having added it through the tenant settings in the web app and the domain ssl will be installed automatically and will be routed to your app. 141 | 142 | ## Why is an apex domain required 143 | 144 | It is not required unless you do not have the _Entreprise plan_. Because of you were to set `PUBLIC_DOMAIN` to `sub.example.com`, Cloudflare will not install SSL Certificates for 3rd layer `*.sub.example.com` unless you are on `Entreprise Plan`. So it's just easier using an apex domain. 145 | 146 | ## Can this be deployed anywhere besides Cloudflare 147 | 148 | Yes you can deploy this anywhere, Cloudflare for SaaS would still work, but you will have to configure your proxy to route the wildcard subdomain of your apex, and the custom tenant domains to your application. You can therefore skip all steps of `Deployment to Cloudflare Workers` except for the activation of `Cloudflare For SaaS` step. 149 | 150 | ## Contributing 151 | 152 | Contributions are welcome! Please feel free to submit a Pull Request. 153 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://shadcn-svelte.com/schema.json", 3 | "style": "default", 4 | "tailwind": { 5 | "config": "tailwind.config.ts", 6 | "css": "src/app.css", 7 | "baseColor": "slate" 8 | }, 9 | "aliases": { 10 | "components": "$lib/client/components", 11 | "utils": "$lib/client/shadcn-utils" 12 | }, 13 | "typescript": true 14 | } -------------------------------------------------------------------------------- /drizzle.config.central.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@libsql/client"; 2 | import { defineConfig } from "drizzle-kit"; 3 | 4 | const databaseUrlConfig: Config = { 5 | url: process.env.TURSO_CENTRAL_DATABASE_URL!, 6 | authToken: process.env.TURSO_GROUP_AUTH_TOKEN!, 7 | }; 8 | 9 | export default defineConfig({ 10 | schema: [ 11 | "./src/lib/server/db/central/schema.ts", 12 | "./src/lib/server/db/central/relations.ts", 13 | ], 14 | out: "drizzle/central", 15 | dialect: "sqlite", 16 | dbCredentials: databaseUrlConfig, 17 | driver: "turso", 18 | }); 19 | -------------------------------------------------------------------------------- /drizzle.config.tenant.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@libsql/client"; 2 | import { defineConfig } from "drizzle-kit"; 3 | 4 | const databaseUrlConfig: Config = { 5 | url: `libsql://${process.env.TURSO_SCHEMA_DATABASE_NAME!}-${process.env 6 | .TURSO_ORGANIZATION_NAME!}.turso.io`, 7 | authToken: process.env.TURSO_GROUP_AUTH_TOKEN!, 8 | }; 9 | export default defineConfig({ 10 | schema: [ 11 | "./src/lib/server/db/tenant/schema.ts", 12 | "./src/lib/server/db/tenant/relations.ts", 13 | ], 14 | out: "drizzle/tenant", 15 | dialect: "sqlite", 16 | dbCredentials: databaseUrlConfig, 17 | driver: "turso", 18 | }); 19 | -------------------------------------------------------------------------------- /drizzle/central/0000_pink_yellowjacket.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `custom_domains` ( 2 | `custom_domain_id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `custom_domain` text NOT NULL, 4 | `verified` integer DEFAULT false NOT NULL, 5 | `cloudflare_hostname_id` text, 6 | `tenant_id` integer NOT NULL, 7 | `created_at` text DEFAULT current_timestamp NOT NULL, 8 | FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`tenant_id`) ON UPDATE no action ON DELETE no action 9 | ); 10 | --> statement-breakpoint 11 | CREATE TABLE `tenants` ( 12 | `tenant_id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 13 | `name` text NOT NULL, 14 | `subdomain` text NOT NULL, 15 | `database_name` text NOT NULL, 16 | `created_at` text DEFAULT current_timestamp NOT NULL 17 | ); 18 | --> statement-breakpoint 19 | CREATE UNIQUE INDEX `custom_domains_custom_domain_unique` ON `custom_domains` (`custom_domain`);--> statement-breakpoint 20 | CREATE UNIQUE INDEX `tenants_subdomain_unique` ON `tenants` (`subdomain`);--> statement-breakpoint 21 | CREATE UNIQUE INDEX `tenants_database_name_unique` ON `tenants` (`database_name`); -------------------------------------------------------------------------------- /drizzle/central/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "21c749e0-a1e3-430d-8e9a-dd27edc72dba", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "custom_domains": { 8 | "name": "custom_domains", 9 | "columns": { 10 | "custom_domain_id": { 11 | "name": "custom_domain_id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "custom_domain": { 18 | "name": "custom_domain", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "verified": { 25 | "name": "verified", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false, 30 | "default": false 31 | }, 32 | "cloudflare_hostname_id": { 33 | "name": "cloudflare_hostname_id", 34 | "type": "text", 35 | "primaryKey": false, 36 | "notNull": false, 37 | "autoincrement": false 38 | }, 39 | "tenant_id": { 40 | "name": "tenant_id", 41 | "type": "integer", 42 | "primaryKey": false, 43 | "notNull": true, 44 | "autoincrement": false 45 | }, 46 | "created_at": { 47 | "name": "created_at", 48 | "type": "text", 49 | "primaryKey": false, 50 | "notNull": true, 51 | "autoincrement": false, 52 | "default": "current_timestamp" 53 | } 54 | }, 55 | "indexes": { 56 | "custom_domains_custom_domain_unique": { 57 | "name": "custom_domains_custom_domain_unique", 58 | "columns": [ 59 | "custom_domain" 60 | ], 61 | "isUnique": true 62 | } 63 | }, 64 | "foreignKeys": { 65 | "custom_domains_tenant_id_tenants_tenant_id_fk": { 66 | "name": "custom_domains_tenant_id_tenants_tenant_id_fk", 67 | "tableFrom": "custom_domains", 68 | "tableTo": "tenants", 69 | "columnsFrom": [ 70 | "tenant_id" 71 | ], 72 | "columnsTo": [ 73 | "tenant_id" 74 | ], 75 | "onDelete": "no action", 76 | "onUpdate": "no action" 77 | } 78 | }, 79 | "compositePrimaryKeys": {}, 80 | "uniqueConstraints": {} 81 | }, 82 | "tenants": { 83 | "name": "tenants", 84 | "columns": { 85 | "tenant_id": { 86 | "name": "tenant_id", 87 | "type": "integer", 88 | "primaryKey": true, 89 | "notNull": true, 90 | "autoincrement": true 91 | }, 92 | "name": { 93 | "name": "name", 94 | "type": "text", 95 | "primaryKey": false, 96 | "notNull": true, 97 | "autoincrement": false 98 | }, 99 | "subdomain": { 100 | "name": "subdomain", 101 | "type": "text", 102 | "primaryKey": false, 103 | "notNull": true, 104 | "autoincrement": false 105 | }, 106 | "database_name": { 107 | "name": "database_name", 108 | "type": "text", 109 | "primaryKey": false, 110 | "notNull": true, 111 | "autoincrement": false 112 | }, 113 | "created_at": { 114 | "name": "created_at", 115 | "type": "text", 116 | "primaryKey": false, 117 | "notNull": true, 118 | "autoincrement": false, 119 | "default": "current_timestamp" 120 | } 121 | }, 122 | "indexes": { 123 | "tenants_subdomain_unique": { 124 | "name": "tenants_subdomain_unique", 125 | "columns": [ 126 | "subdomain" 127 | ], 128 | "isUnique": true 129 | }, 130 | "tenants_database_name_unique": { 131 | "name": "tenants_database_name_unique", 132 | "columns": [ 133 | "database_name" 134 | ], 135 | "isUnique": true 136 | } 137 | }, 138 | "foreignKeys": {}, 139 | "compositePrimaryKeys": {}, 140 | "uniqueConstraints": {} 141 | } 142 | }, 143 | "enums": {}, 144 | "_meta": { 145 | "schemas": {}, 146 | "tables": {}, 147 | "columns": {} 148 | }, 149 | "internal": { 150 | "indexes": {} 151 | } 152 | } -------------------------------------------------------------------------------- /drizzle/central/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1727654546139, 9 | "tag": "0000_pink_yellowjacket", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /drizzle/tenant/0000_modern_sir_ram.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `sessions` ( 2 | `session_id` text PRIMARY KEY NOT NULL, 3 | `user_id` integer NOT NULL, 4 | `expires_at` integer NOT NULL, 5 | FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON UPDATE no action ON DELETE no action 6 | ); 7 | --> statement-breakpoint 8 | CREATE TABLE `tasks` ( 9 | `task_id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 10 | `title` text NOT NULL, 11 | `status` text NOT NULL, 12 | `label` text NOT NULL, 13 | `created_by` integer NOT NULL, 14 | `priority` text NOT NULL, 15 | `created_at` text DEFAULT current_timestamp NOT NULL, 16 | FOREIGN KEY (`created_by`) REFERENCES `users`(`user_id`) ON UPDATE no action ON DELETE no action 17 | ); 18 | --> statement-breakpoint 19 | CREATE TABLE `users` ( 20 | `user_id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 21 | `username` text NOT NULL, 22 | `password` text NOT NULL, 23 | `role` text NOT NULL, 24 | `created_at` text DEFAULT current_timestamp NOT NULL 25 | ); 26 | --> statement-breakpoint 27 | CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`); -------------------------------------------------------------------------------- /drizzle/tenant/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "a734262c-2486-4387-995c-e89ca081e515", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "sessions": { 8 | "name": "sessions", 9 | "columns": { 10 | "session_id": { 11 | "name": "session_id", 12 | "type": "text", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "user_id": { 18 | "name": "user_id", 19 | "type": "integer", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "expires_at": { 25 | "name": "expires_at", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | } 31 | }, 32 | "indexes": {}, 33 | "foreignKeys": { 34 | "sessions_user_id_users_user_id_fk": { 35 | "name": "sessions_user_id_users_user_id_fk", 36 | "tableFrom": "sessions", 37 | "tableTo": "users", 38 | "columnsFrom": [ 39 | "user_id" 40 | ], 41 | "columnsTo": [ 42 | "user_id" 43 | ], 44 | "onDelete": "no action", 45 | "onUpdate": "no action" 46 | } 47 | }, 48 | "compositePrimaryKeys": {}, 49 | "uniqueConstraints": {} 50 | }, 51 | "tasks": { 52 | "name": "tasks", 53 | "columns": { 54 | "task_id": { 55 | "name": "task_id", 56 | "type": "integer", 57 | "primaryKey": true, 58 | "notNull": true, 59 | "autoincrement": true 60 | }, 61 | "title": { 62 | "name": "title", 63 | "type": "text", 64 | "primaryKey": false, 65 | "notNull": true, 66 | "autoincrement": false 67 | }, 68 | "status": { 69 | "name": "status", 70 | "type": "text", 71 | "primaryKey": false, 72 | "notNull": true, 73 | "autoincrement": false 74 | }, 75 | "label": { 76 | "name": "label", 77 | "type": "text", 78 | "primaryKey": false, 79 | "notNull": true, 80 | "autoincrement": false 81 | }, 82 | "created_by": { 83 | "name": "created_by", 84 | "type": "integer", 85 | "primaryKey": false, 86 | "notNull": true, 87 | "autoincrement": false 88 | }, 89 | "priority": { 90 | "name": "priority", 91 | "type": "text", 92 | "primaryKey": false, 93 | "notNull": true, 94 | "autoincrement": false 95 | }, 96 | "created_at": { 97 | "name": "created_at", 98 | "type": "text", 99 | "primaryKey": false, 100 | "notNull": true, 101 | "autoincrement": false, 102 | "default": "current_timestamp" 103 | } 104 | }, 105 | "indexes": {}, 106 | "foreignKeys": { 107 | "tasks_created_by_users_user_id_fk": { 108 | "name": "tasks_created_by_users_user_id_fk", 109 | "tableFrom": "tasks", 110 | "tableTo": "users", 111 | "columnsFrom": [ 112 | "created_by" 113 | ], 114 | "columnsTo": [ 115 | "user_id" 116 | ], 117 | "onDelete": "no action", 118 | "onUpdate": "no action" 119 | } 120 | }, 121 | "compositePrimaryKeys": {}, 122 | "uniqueConstraints": {} 123 | }, 124 | "users": { 125 | "name": "users", 126 | "columns": { 127 | "user_id": { 128 | "name": "user_id", 129 | "type": "integer", 130 | "primaryKey": true, 131 | "notNull": true, 132 | "autoincrement": true 133 | }, 134 | "username": { 135 | "name": "username", 136 | "type": "text", 137 | "primaryKey": false, 138 | "notNull": true, 139 | "autoincrement": false 140 | }, 141 | "password": { 142 | "name": "password", 143 | "type": "text", 144 | "primaryKey": false, 145 | "notNull": true, 146 | "autoincrement": false 147 | }, 148 | "role": { 149 | "name": "role", 150 | "type": "text", 151 | "primaryKey": false, 152 | "notNull": true, 153 | "autoincrement": false 154 | }, 155 | "created_at": { 156 | "name": "created_at", 157 | "type": "text", 158 | "primaryKey": false, 159 | "notNull": true, 160 | "autoincrement": false, 161 | "default": "current_timestamp" 162 | } 163 | }, 164 | "indexes": { 165 | "users_username_unique": { 166 | "name": "users_username_unique", 167 | "columns": [ 168 | "username" 169 | ], 170 | "isUnique": true 171 | } 172 | }, 173 | "foreignKeys": {}, 174 | "compositePrimaryKeys": {}, 175 | "uniqueConstraints": {} 176 | } 177 | }, 178 | "enums": {}, 179 | "_meta": { 180 | "schemas": {}, 181 | "tables": {}, 182 | "columns": {} 183 | }, 184 | "internal": { 185 | "indexes": {} 186 | } 187 | } -------------------------------------------------------------------------------- /drizzle/tenant/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1727654535450, 9 | "tag": "0000_modern_sir_ram", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sveltekit-multi-tenancy", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "pnpm run build && wrangler pages dev", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "deploy": "pnpm run build && wrangler pages deploy", 12 | "cf-typegen": "wrangler types && mv worker-configuration.d.ts src/", 13 | "db:tenant": "drizzle-kit --config drizzle.config.tenant.ts", 14 | "db:central": "drizzle-kit --config drizzle.config.central.ts" 15 | }, 16 | "devDependencies": { 17 | "@cloudflare/workers-types": "^4.20240903.0", 18 | "@sveltejs/adapter-auto": "^3.2.4", 19 | "@sveltejs/adapter-cloudflare": "^4.7.2", 20 | "@sveltejs/adapter-cloudflare-workers": "^2.5.4", 21 | "@sveltejs/kit": "^2.5.26", 22 | "@sveltejs/vite-plugin-svelte": "^3.1.2", 23 | "@tailwindcss/typography": "^0.5.14", 24 | "@types/node": "^22.5.4", 25 | "autoprefixer": "^10.4.20", 26 | "drizzle-kit": "^0.24.2", 27 | "drizzle-zod": "^0.5.1", 28 | "svelte": "^4.2.19", 29 | "svelte-check": "^3.8.6", 30 | "sveltekit-superforms": "^2.17.0", 31 | "tailwindcss": "^3.4.9", 32 | "typescript": "^5.5.4", 33 | "vite": "^5.4.3", 34 | "wrangler": "^3.75.0", 35 | "zod": "^3.23.8" 36 | }, 37 | "type": "module", 38 | "dependencies": { 39 | "@libsql/client": "^0.10.0", 40 | "@lucia-auth/adapter-drizzle": "^1.1.0", 41 | "@tursodatabase/api": "^1.8.1", 42 | "bits-ui": "^0.21.13", 43 | "clsx": "^2.1.1", 44 | "cmdk-sv": "^0.0.18", 45 | "drizzle-orm": "^0.33.0", 46 | "formsnap": "^1.0.1", 47 | "lucia": "^3.2.0", 48 | "lucide-svelte": "^0.439.0", 49 | "mode-watcher": "^0.4.1", 50 | "svelte-headless-table": "^0.18.2", 51 | "svelte-radix": "^1.1.1", 52 | "tailwind-merge": "^2.5.2", 53 | "tailwind-variants": "^0.2.1" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 222.2 84% 4.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 222.2 84% 4.9%; 13 | --primary: 221.2 83.2% 53.3%; 14 | --primary-foreground: 210 40% 98%; 15 | --secondary: 210 40% 96.1%; 16 | --secondary-foreground: 222.2 47.4% 11.2%; 17 | --muted: 210 40% 96.1%; 18 | --muted-foreground: 215.4 16.3% 46.9%; 19 | --accent: 210 40% 96.1%; 20 | --accent-foreground: 222.2 47.4% 11.2%; 21 | --destructive: 0 72.22% 50.59%; 22 | --destructive-foreground: 210 40% 98%; 23 | --border: 214.3 31.8% 91.4%; 24 | --input: 214.3 31.8% 91.4%; 25 | --ring: 221.2 83.2% 53.3%; 26 | --radius: 0.5rem; 27 | } 28 | .dark { 29 | --background: 222.2 84% 4.9%; 30 | --foreground: 210 40% 98%; 31 | --card: 222.2 84% 4.9%; 32 | --card-foreground: 210 40% 98%; 33 | --popover: 222.2 84% 4.9%; 34 | --popover-foreground: 210 40% 98%; 35 | --primary: 217.2 91.2% 59.8%; 36 | --primary-foreground: 222.2 47.4% 11.2%; 37 | --secondary: 217.2 32.6% 17.5%; 38 | --secondary-foreground: 210 40% 98%; 39 | --muted: 217.2 32.6% 17.5%; 40 | --muted-foreground: 215 20.2% 65.1%; 41 | --accent: 217.2 32.6% 17.5%; 42 | --accent-foreground: 210 40% 98%; 43 | --destructive: 0 62.8% 30.6%; 44 | --destructive-foreground: 210 40% 98%; 45 | --border: 217.2 32.6% 17.5%; 46 | --input: 217.2 32.6% 17.5%; 47 | --ring: 224.3 76.3% 48%; 48 | } 49 | } 50 | @layer base { 51 | * { 52 | @apply border-border; 53 | } 54 | body { 55 | @apply bg-background text-foreground; 56 | } 57 | } -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | interface Platform { 6 | env: Env; 7 | cf: CfProperties; 8 | ctx: ExecutionContext; 9 | } 10 | interface Locals { 11 | user: import("lucia").User | null; 12 | session: import("lucia").Session | null; 13 | tenantDb: import("$lib/server/db/tenant").TenantDbType | null; 14 | lucia: import("$lib/server/auth").LuciaType | null; 15 | tenantInfo: { 16 | tenantId: number; 17 | name: string; 18 | subdomain: string; 19 | createdAt: string; 20 | databaseName: string; 21 | } | null; 22 | } 23 | } 24 | } 25 | 26 | export {}; 27 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { PUBLIC_DOMAIN } from "$env/static/public"; 2 | import { getLuciaForTenant } from "$lib/server/auth"; 3 | import { getTenant } from "$lib/server/utils/getTenantInformation"; 4 | import { error, type Handle } from "@sveltejs/kit"; 5 | 6 | export const handle: Handle = async ({ event, resolve }) => { 7 | /* disallow access to PUBLIC_DOMAIN/tenant, this is optional */ 8 | const { host, pathname } = event.url; 9 | if (host === PUBLIC_DOMAIN) { 10 | if (pathname.startsWith("/tenant")) { 11 | error(404, { message: "Not Found" }); 12 | } else { 13 | return resolve(event); 14 | } 15 | } 16 | 17 | /* if no database returned for given subdomain or custom domain then the tenant does not exist */ 18 | 19 | const tenant = await getTenant(host); 20 | if (!tenant) { 21 | error(404, { message: "Not Found" }); 22 | } 23 | event.locals.tenantDb = tenant.tenantDb; 24 | event.locals.tenantInfo = tenant.tenantInfo!; 25 | 26 | /* authenticate users of tenants with lucia */ 27 | const lucia = getLuciaForTenant(tenant.tenantDb); 28 | event.locals.lucia = lucia; 29 | const sessionId = event.cookies.get(lucia.sessionCookieName); 30 | if (!sessionId) { 31 | event.locals.user = null; 32 | event.locals.session = null; 33 | return resolve(event); 34 | } 35 | const { session, user } = await lucia.validateSession(sessionId); 36 | if (session && session.fresh) { 37 | const sessionCookie = lucia.createSessionCookie(session.id); 38 | event.cookies.set(sessionCookie.name, sessionCookie.value, { 39 | path: ".", 40 | ...sessionCookie.attributes, 41 | }); 42 | } 43 | if (!session) { 44 | const sessionCookie = lucia.createBlankSessionCookie(); 45 | event.cookies.set(sessionCookie.name, sessionCookie.value, { 46 | path: ".", 47 | ...sessionCookie.attributes, 48 | }); 49 | } 50 | 51 | event.locals.user = user; 52 | event.locals.session = session; 53 | return resolve(event); 54 | }; 55 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { getDomainAndType } from "$lib/util"; 2 | import type { Reroute } from "@sveltejs/kit"; 3 | 4 | export const reroute: Reroute = ({ url }) => { 5 | const domain = getDomainAndType(url.host); 6 | if (domain.type === "appDomain") { 7 | return url.pathname; 8 | } else { 9 | const tenantDomain = domain.domain; 10 | return `/tenant/${tenantDomain}${url.pathname}`; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/avatar/avatar-fallback.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/avatar/avatar-image.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/avatar/avatar.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/avatar/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./avatar.svelte"; 2 | import Image from "./avatar-image.svelte"; 3 | import Fallback from "./avatar-fallback.svelte"; 4 | 5 | export { 6 | Root, 7 | Image, 8 | Fallback, 9 | // 10 | Root as Avatar, 11 | Image as AvatarImage, 12 | Fallback as AvatarFallback, 13 | }; 14 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/badge/badge.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/badge/index.ts: -------------------------------------------------------------------------------- 1 | import { type VariantProps, tv } from "tailwind-variants"; 2 | export { default as Badge } from "./badge.svelte"; 3 | 4 | export const badgeVariants = tv({ 5 | base: "focus:ring-ring inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2", 6 | variants: { 7 | variant: { 8 | default: "bg-primary text-primary-foreground hover:bg-primary/80 border-transparent", 9 | secondary: 10 | "bg-secondary text-secondary-foreground hover:bg-secondary/80 border-transparent", 11 | destructive: 12 | "bg-destructive text-destructive-foreground hover:bg-destructive/80 border-transparent", 13 | outline: "text-foreground", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | }); 20 | 21 | export type Variant = VariantProps["variant"]; 22 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/button/button.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/button/index.ts: -------------------------------------------------------------------------------- 1 | import { type VariantProps, tv } from "tailwind-variants"; 2 | import type { Button as ButtonPrimitive } from "bits-ui"; 3 | import Root from "./button.svelte"; 4 | 5 | const buttonVariants = tv({ 6 | base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 7 | variants: { 8 | variant: { 9 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 10 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", 11 | outline: 12 | "border-input bg-background hover:bg-accent hover:text-accent-foreground border", 13 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", 14 | ghost: "hover:bg-accent hover:text-accent-foreground", 15 | link: "text-primary underline-offset-4 hover:underline", 16 | }, 17 | size: { 18 | default: "h-10 px-4 py-2", 19 | sm: "h-9 rounded-md px-3", 20 | lg: "h-11 rounded-md px-8", 21 | icon: "h-10 w-10", 22 | }, 23 | }, 24 | defaultVariants: { 25 | variant: "default", 26 | size: "default", 27 | }, 28 | }); 29 | 30 | type Variant = VariantProps["variant"]; 31 | type Size = VariantProps["size"]; 32 | 33 | type Props = ButtonPrimitive.Props & { 34 | variant?: Variant; 35 | size?: Size; 36 | }; 37 | 38 | type Events = ButtonPrimitive.Events; 39 | 40 | export { 41 | Root, 42 | type Props, 43 | type Events, 44 | // 45 | Root as Button, 46 | type Props as ButtonProps, 47 | type Events as ButtonEvents, 48 | buttonVariants, 49 | }; 50 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/card/card-content.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | 13 |
14 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/card/card-description.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |

12 | 13 |

14 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/card/card-footer.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | 13 |
14 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/card/card-header.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | 13 |
14 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/card/card-title.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/card/card.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
15 | 16 |
17 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/card/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./card.svelte"; 2 | import Content from "./card-content.svelte"; 3 | import Description from "./card-description.svelte"; 4 | import Footer from "./card-footer.svelte"; 5 | import Header from "./card-header.svelte"; 6 | import Title from "./card-title.svelte"; 7 | 8 | export { 9 | Root, 10 | Content, 11 | Description, 12 | Footer, 13 | Header, 14 | Title, 15 | // 16 | Root as Card, 17 | Content as CardContent, 18 | Description as CardDescription, 19 | Footer as CardFooter, 20 | Header as CardHeader, 21 | Title as CardTitle, 22 | }; 23 | 24 | export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; 25 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/checkbox/checkbox.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 24 | 29 | {#if isChecked} 30 | 31 | {:else if isIndeterminate} 32 | 33 | {/if} 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/checkbox/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./checkbox.svelte"; 2 | export { 3 | Root, 4 | // 5 | Root as Checkbox, 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/command/command-dialog.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/command/command-empty.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/command/command-group.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/command/command-input.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 | 15 | 23 |
24 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/command/command-item.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/command/command-list.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/command/command-separator.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/command/command-shortcut.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/command/command.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/command/index.ts: -------------------------------------------------------------------------------- 1 | import { Command as CommandPrimitive } from "cmdk-sv"; 2 | 3 | import Root from "./command.svelte"; 4 | import Dialog from "./command-dialog.svelte"; 5 | import Empty from "./command-empty.svelte"; 6 | import Group from "./command-group.svelte"; 7 | import Item from "./command-item.svelte"; 8 | import Input from "./command-input.svelte"; 9 | import List from "./command-list.svelte"; 10 | import Separator from "./command-separator.svelte"; 11 | import Shortcut from "./command-shortcut.svelte"; 12 | 13 | const Loading = CommandPrimitive.Loading; 14 | 15 | export { 16 | Root, 17 | Dialog, 18 | Empty, 19 | Group, 20 | Item, 21 | Input, 22 | List, 23 | Separator, 24 | Shortcut, 25 | Loading, 26 | // 27 | Root as Command, 28 | Dialog as CommandDialog, 29 | Empty as CommandEmpty, 30 | Group as CommandGroup, 31 | Item as CommandItem, 32 | Input as CommandInput, 33 | List as CommandList, 34 | Separator as CommandSeparator, 35 | Shortcut as CommandShortcut, 36 | Loading as CommandLoading, 37 | }; 38 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dialog/dialog-content.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 28 | 29 | 32 | 33 | Close 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dialog/dialog-description.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dialog/dialog-footer.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
15 | 16 |
17 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dialog/dialog-header.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | 13 |
14 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dialog/dialog-overlay.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 22 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dialog/dialog-portal.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dialog/dialog-title.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dialog/index.ts: -------------------------------------------------------------------------------- 1 | import { Dialog as DialogPrimitive } from "bits-ui"; 2 | 3 | import Title from "./dialog-title.svelte"; 4 | import Portal from "./dialog-portal.svelte"; 5 | import Footer from "./dialog-footer.svelte"; 6 | import Header from "./dialog-header.svelte"; 7 | import Overlay from "./dialog-overlay.svelte"; 8 | import Content from "./dialog-content.svelte"; 9 | import Description from "./dialog-description.svelte"; 10 | 11 | const Root = DialogPrimitive.Root; 12 | const Trigger = DialogPrimitive.Trigger; 13 | const Close = DialogPrimitive.Close; 14 | 15 | export { 16 | Root, 17 | Title, 18 | Portal, 19 | Footer, 20 | Header, 21 | Trigger, 22 | Overlay, 23 | Content, 24 | Description, 25 | Close, 26 | // 27 | Root as Dialog, 28 | Title as DialogTitle, 29 | Portal as DialogPortal, 30 | Footer as DialogFooter, 31 | Header as DialogHeader, 32 | Trigger as DialogTrigger, 33 | Overlay as DialogOverlay, 34 | Content as DialogContent, 35 | Description as DialogDescription, 36 | Close as DialogClose, 37 | }; 38 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dropdown-menu/dropdown-menu-content.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dropdown-menu/dropdown-menu-item.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dropdown-menu/dropdown-menu-label.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dropdown-menu/dropdown-menu-separator.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 15 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/dropdown-menu/index.ts: -------------------------------------------------------------------------------- 1 | import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; 2 | import Item from "./dropdown-menu-item.svelte"; 3 | import Label from "./dropdown-menu-label.svelte"; 4 | import Content from "./dropdown-menu-content.svelte"; 5 | import Shortcut from "./dropdown-menu-shortcut.svelte"; 6 | import RadioItem from "./dropdown-menu-radio-item.svelte"; 7 | import Separator from "./dropdown-menu-separator.svelte"; 8 | import RadioGroup from "./dropdown-menu-radio-group.svelte"; 9 | import SubContent from "./dropdown-menu-sub-content.svelte"; 10 | import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; 11 | import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; 12 | 13 | const Sub = DropdownMenuPrimitive.Sub; 14 | const Root = DropdownMenuPrimitive.Root; 15 | const Trigger = DropdownMenuPrimitive.Trigger; 16 | const Group = DropdownMenuPrimitive.Group; 17 | 18 | export { 19 | Sub, 20 | Root, 21 | Item, 22 | Label, 23 | Group, 24 | Trigger, 25 | Content, 26 | Shortcut, 27 | Separator, 28 | RadioItem, 29 | SubContent, 30 | SubTrigger, 31 | RadioGroup, 32 | CheckboxItem, 33 | // 34 | Root as DropdownMenu, 35 | Sub as DropdownMenuSub, 36 | Item as DropdownMenuItem, 37 | Label as DropdownMenuLabel, 38 | Group as DropdownMenuGroup, 39 | Content as DropdownMenuContent, 40 | Trigger as DropdownMenuTrigger, 41 | Shortcut as DropdownMenuShortcut, 42 | RadioItem as DropdownMenuRadioItem, 43 | Separator as DropdownMenuSeparator, 44 | RadioGroup as DropdownMenuRadioGroup, 45 | SubContent as DropdownMenuSubContent, 46 | SubTrigger as DropdownMenuSubTrigger, 47 | CheckboxItem as DropdownMenuCheckboxItem, 48 | }; 49 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/form/form-button.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/form/form-description.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/form/form-element-field.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | 21 | 22 |
23 | 24 |
25 |
26 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/form/form-field-errors.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 21 | 22 | {#each errors as error} 23 |
{error}
24 | {/each} 25 |
26 |
27 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/form/form-field.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | 21 | 22 |
23 | 24 |
25 |
26 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/form/form-fieldset.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | 20 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/form/form-label.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 18 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/form/form-legend.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/form/index.ts: -------------------------------------------------------------------------------- 1 | import * as FormPrimitive from "formsnap"; 2 | import Description from "./form-description.svelte"; 3 | import Label from "./form-label.svelte"; 4 | import FieldErrors from "./form-field-errors.svelte"; 5 | import Field from "./form-field.svelte"; 6 | import Fieldset from "./form-fieldset.svelte"; 7 | import Legend from "./form-legend.svelte"; 8 | import ElementField from "./form-element-field.svelte"; 9 | import Button from "./form-button.svelte"; 10 | 11 | const Control = FormPrimitive.Control; 12 | 13 | export { 14 | Field, 15 | Control, 16 | Label, 17 | Button, 18 | FieldErrors, 19 | Description, 20 | Fieldset, 21 | Legend, 22 | ElementField, 23 | // 24 | Field as FormField, 25 | Control as FormControl, 26 | Description as FormDescription, 27 | Label as FormLabel, 28 | FieldErrors as FormFieldErrors, 29 | Fieldset as FormFieldset, 30 | Legend as FormLegend, 31 | ElementField as FormElementField, 32 | Button as FormButton, 33 | }; 34 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/input/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./input.svelte"; 2 | 3 | export type FormInputEvent = T & { 4 | currentTarget: EventTarget & HTMLInputElement; 5 | }; 6 | export type InputEvents = { 7 | blur: FormInputEvent; 8 | change: FormInputEvent; 9 | click: FormInputEvent; 10 | focus: FormInputEvent; 11 | focusin: FormInputEvent; 12 | focusout: FormInputEvent; 13 | keydown: FormInputEvent; 14 | keypress: FormInputEvent; 15 | keyup: FormInputEvent; 16 | mouseover: FormInputEvent; 17 | mouseenter: FormInputEvent; 18 | mouseleave: FormInputEvent; 19 | mousemove: FormInputEvent; 20 | paste: FormInputEvent; 21 | input: FormInputEvent; 22 | wheel: FormInputEvent; 23 | }; 24 | 25 | export { 26 | Root, 27 | // 28 | Root as Input, 29 | }; 30 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/input/input.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 43 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/label/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./label.svelte"; 2 | 3 | export { 4 | Root, 5 | // 6 | Root as Label, 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/label/label.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/popover/index.ts: -------------------------------------------------------------------------------- 1 | import { Popover as PopoverPrimitive } from "bits-ui"; 2 | import Content from "./popover-content.svelte"; 3 | const Root = PopoverPrimitive.Root; 4 | const Trigger = PopoverPrimitive.Trigger; 5 | const Close = PopoverPrimitive.Close; 6 | 7 | export { 8 | Root, 9 | Content, 10 | Trigger, 11 | Close, 12 | // 13 | Root as Popover, 14 | Content as PopoverContent, 15 | Trigger as PopoverTrigger, 16 | Close as PopoverClose, 17 | }; 18 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/popover/popover-content.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/radio-group/index.ts: -------------------------------------------------------------------------------- 1 | import { RadioGroup as RadioGroupPrimitive } from "bits-ui"; 2 | 3 | import Root from "./radio-group.svelte"; 4 | import Item from "./radio-group-item.svelte"; 5 | const Input = RadioGroupPrimitive.Input; 6 | 7 | export { 8 | Root, 9 | Input, 10 | Item, 11 | // 12 | Root as RadioGroup, 13 | Input as RadioGroupInput, 14 | Item as RadioGroupItem, 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/radio-group/radio-group-item.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 23 |
24 | 25 | 26 | 27 |
28 |
29 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/radio-group/radio-group.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/select/index.ts: -------------------------------------------------------------------------------- 1 | import { Select as SelectPrimitive } from "bits-ui"; 2 | 3 | import Label from "./select-label.svelte"; 4 | import Item from "./select-item.svelte"; 5 | import Content from "./select-content.svelte"; 6 | import Trigger from "./select-trigger.svelte"; 7 | import Separator from "./select-separator.svelte"; 8 | 9 | const Root = SelectPrimitive.Root; 10 | const Group = SelectPrimitive.Group; 11 | const Input = SelectPrimitive.Input; 12 | const Value = SelectPrimitive.Value; 13 | 14 | export { 15 | Root, 16 | Group, 17 | Input, 18 | Label, 19 | Item, 20 | Value, 21 | Content, 22 | Trigger, 23 | Separator, 24 | // 25 | Root as Select, 26 | Group as SelectGroup, 27 | Input as SelectInput, 28 | Label as SelectLabel, 29 | Item as SelectItem, 30 | Value as SelectValue, 31 | Content as SelectContent, 32 | Trigger as SelectTrigger, 33 | Separator as SelectSeparator, 34 | }; 35 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/select/select-content.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | 36 |
37 | 38 |
39 |
40 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/select/select-item.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {label || value} 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/select/select-label.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/select/select-separator.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/select/select-trigger.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | span]:text-muted-foreground flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", 16 | className 17 | )} 18 | {...$$restProps} 19 | let:builder 20 | on:click 21 | on:keydown 22 | > 23 | 24 |
25 | 26 |
27 |
28 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/separator/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./separator.svelte"; 2 | 3 | export { 4 | Root, 5 | // 6 | Root as Separator, 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/separator/separator.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 23 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/sheet/index.ts: -------------------------------------------------------------------------------- 1 | import { Dialog as SheetPrimitive } from "bits-ui"; 2 | import { type VariantProps, tv } from "tailwind-variants"; 3 | 4 | import Portal from "./sheet-portal.svelte"; 5 | import Overlay from "./sheet-overlay.svelte"; 6 | import Content from "./sheet-content.svelte"; 7 | import Header from "./sheet-header.svelte"; 8 | import Footer from "./sheet-footer.svelte"; 9 | import Title from "./sheet-title.svelte"; 10 | import Description from "./sheet-description.svelte"; 11 | 12 | const Root = SheetPrimitive.Root; 13 | const Close = SheetPrimitive.Close; 14 | const Trigger = SheetPrimitive.Trigger; 15 | 16 | export { 17 | Root, 18 | Close, 19 | Trigger, 20 | Portal, 21 | Overlay, 22 | Content, 23 | Header, 24 | Footer, 25 | Title, 26 | Description, 27 | // 28 | Root as Sheet, 29 | Close as SheetClose, 30 | Trigger as SheetTrigger, 31 | Portal as SheetPortal, 32 | Overlay as SheetOverlay, 33 | Content as SheetContent, 34 | Header as SheetHeader, 35 | Footer as SheetFooter, 36 | Title as SheetTitle, 37 | Description as SheetDescription, 38 | }; 39 | 40 | export const sheetVariants = tv({ 41 | base: "bg-background fixed z-50 gap-4 p-6 shadow-lg", 42 | variants: { 43 | side: { 44 | top: "inset-x-0 top-0 border-b", 45 | bottom: "inset-x-0 bottom-0 border-t", 46 | left: "inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm", 47 | right: "inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm", 48 | }, 49 | }, 50 | defaultVariants: { 51 | side: "right", 52 | }, 53 | }); 54 | 55 | export const sheetTransitions = { 56 | top: { 57 | in: { 58 | y: "-100%", 59 | duration: 500, 60 | opacity: 1, 61 | }, 62 | out: { 63 | y: "-100%", 64 | duration: 300, 65 | opacity: 1, 66 | }, 67 | }, 68 | bottom: { 69 | in: { 70 | y: "100%", 71 | duration: 500, 72 | opacity: 1, 73 | }, 74 | out: { 75 | y: "100%", 76 | duration: 300, 77 | opacity: 1, 78 | }, 79 | }, 80 | left: { 81 | in: { 82 | x: "-100%", 83 | duration: 500, 84 | opacity: 1, 85 | }, 86 | out: { 87 | x: "-100%", 88 | duration: 300, 89 | opacity: 1, 90 | }, 91 | }, 92 | right: { 93 | in: { 94 | x: "100%", 95 | duration: 500, 96 | opacity: 1, 97 | }, 98 | out: { 99 | x: "100%", 100 | duration: 300, 101 | opacity: 1, 102 | }, 103 | }, 104 | }; 105 | 106 | export type Side = VariantProps["side"]; 107 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/sheet/sheet-content.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | 30 | 31 | 39 | 40 | 43 | 44 | Close 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/sheet/sheet-description.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/sheet/sheet-footer.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
15 | 16 |
17 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/sheet/sheet-header.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | 13 |
14 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/sheet/sheet-overlay.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 22 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/sheet/sheet-portal.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/sheet/sheet-title.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/table/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./table.svelte"; 2 | import Body from "./table-body.svelte"; 3 | import Caption from "./table-caption.svelte"; 4 | import Cell from "./table-cell.svelte"; 5 | import Footer from "./table-footer.svelte"; 6 | import Head from "./table-head.svelte"; 7 | import Header from "./table-header.svelte"; 8 | import Row from "./table-row.svelte"; 9 | 10 | export { 11 | Root, 12 | Body, 13 | Caption, 14 | Cell, 15 | Footer, 16 | Head, 17 | Header, 18 | Row, 19 | // 20 | Root as Table, 21 | Body as TableBody, 22 | Caption as TableCaption, 23 | Cell as TableCell, 24 | Footer as TableFooter, 25 | Head as TableHead, 26 | Header as TableHeader, 27 | Row as TableRow, 28 | }; 29 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/table/table-body.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/table/table-caption.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/table/table-cell.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/table/table-footer.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/table/table-head.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/table/table-header.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/table/table-row.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/lib/client/components/ui/table/table.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | 13 | 14 |
15 |
16 | -------------------------------------------------------------------------------- /src/lib/client/shadcn-utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | import { cubicOut } from "svelte/easing"; 4 | import type { TransitionConfig } from "svelte/transition"; 5 | 6 | export function cn(...inputs: ClassValue[]) { 7 | return twMerge(clsx(inputs)); 8 | } 9 | 10 | type FlyAndScaleParams = { 11 | y?: number; 12 | x?: number; 13 | start?: number; 14 | duration?: number; 15 | }; 16 | 17 | export const flyAndScale = ( 18 | node: Element, 19 | params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 } 20 | ): TransitionConfig => { 21 | const style = getComputedStyle(node); 22 | const transform = style.transform === "none" ? "" : style.transform; 23 | 24 | const scaleConversion = ( 25 | valueA: number, 26 | scaleA: [number, number], 27 | scaleB: [number, number] 28 | ) => { 29 | const [minA, maxA] = scaleA; 30 | const [minB, maxB] = scaleB; 31 | 32 | const percentage = (valueA - minA) / (maxA - minA); 33 | const valueB = percentage * (maxB - minB) + minB; 34 | 35 | return valueB; 36 | }; 37 | 38 | const styleToString = ( 39 | style: Record 40 | ): string => { 41 | return Object.keys(style).reduce((str, key) => { 42 | if (style[key] === undefined) return str; 43 | return str + `${key}:${style[key]};`; 44 | }, ""); 45 | }; 46 | 47 | return { 48 | duration: params.duration ?? 200, 49 | delay: 0, 50 | css: (t) => { 51 | const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]); 52 | const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]); 53 | const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]); 54 | 55 | return styleToString({ 56 | transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`, 57 | opacity: t 58 | }); 59 | }, 60 | easing: cubicOut 61 | }; 62 | }; -------------------------------------------------------------------------------- /src/lib/server/auth.ts: -------------------------------------------------------------------------------- 1 | import { Lucia } from "lucia"; 2 | import { dev } from "$app/environment"; 3 | import type { TenantDbType } from "./db/tenant"; 4 | import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle"; 5 | import { sessions, users } from "./db/tenant/schema"; 6 | 7 | export function getLuciaForTenant(db: TenantDbType) { 8 | // @ts-ignore fix later, idk why it's complaining about db 9 | const adapter = new DrizzleSQLiteAdapter(db, sessions, users); 10 | return new Lucia(adapter, { 11 | sessionCookie: { 12 | attributes: { 13 | secure: !dev, 14 | }, 15 | }, 16 | getUserAttributes: (attributes) => { 17 | return { 18 | id: attributes.id, 19 | username: attributes.username, 20 | role: attributes.role, 21 | }; 22 | }, 23 | }); 24 | } 25 | 26 | declare module "lucia" { 27 | interface Register { 28 | Lucia: ReturnType; 29 | DatabaseUserAttributes: { 30 | id: number; 31 | username: string; 32 | role: "admin" | "normal"; 33 | }; 34 | UserId: number; 35 | } 36 | } 37 | 38 | export type LuciaType = ReturnType; 39 | -------------------------------------------------------------------------------- /src/lib/server/db/central/index.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@libsql/client"; 2 | import { drizzle } from "drizzle-orm/libsql"; 3 | import { 4 | TURSO_CENTRAL_DATABASE_URL, 5 | TURSO_GROUP_AUTH_TOKEN, 6 | } from "$env/static/private"; 7 | import * as centralSchema from "./schema"; 8 | import * as centralRelations from "./relations"; 9 | 10 | export const centralTSchema = { ...centralSchema, ...centralRelations }; 11 | 12 | const authToken = TURSO_GROUP_AUTH_TOKEN; 13 | const centralDbUrl = TURSO_CENTRAL_DATABASE_URL; 14 | 15 | const centralTursoClient = createClient({ url: centralDbUrl, authToken }); 16 | export const centralDb = drizzle(centralTursoClient, { 17 | schema: centralTSchema, 18 | }); 19 | -------------------------------------------------------------------------------- /src/lib/server/db/central/relations.ts: -------------------------------------------------------------------------------- 1 | import { relations } from "drizzle-orm"; 2 | import { customDomains, tenants } from "./schema"; 3 | 4 | export const tenantRelations = relations(tenants, ({ many }) => ({ 5 | customDomains: many(customDomains), 6 | })); 7 | 8 | export const customDomainRelations = relations(customDomains, ({ one }) => ({ 9 | tenant: one(tenants, { 10 | fields: [customDomains.tenantId], 11 | references: [tenants.tenantId], 12 | }), 13 | })); 14 | -------------------------------------------------------------------------------- /src/lib/server/db/central/schema.ts: -------------------------------------------------------------------------------- 1 | import { sql } from "drizzle-orm"; 2 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 3 | 4 | export const tenants = sqliteTable("tenants", { 5 | tenantId: integer("tenant_id").primaryKey({ autoIncrement: true }), 6 | name: text("name").notNull(), 7 | subdomain: text("subdomain").notNull().unique(), 8 | databaseName: text("database_name").notNull().unique(), 9 | createdAt: text("created_at") 10 | .notNull() 11 | .default(sql`current_timestamp`), 12 | }); 13 | 14 | export const customDomains = sqliteTable("custom_domains", { 15 | customDomainId: integer("custom_domain_id").primaryKey({ 16 | autoIncrement: true, 17 | }), 18 | customDomain: text("custom_domain").unique().notNull(), 19 | verified: integer("verified", { mode: "boolean" }).notNull().default(false), 20 | cloudflareHostnameId: text("cloudflare_hostname_id"), 21 | tenantId: integer("tenant_id") 22 | .notNull() 23 | .references(() => tenants.tenantId), 24 | createdAt: text("created_at") 25 | .notNull() 26 | .default(sql`current_timestamp`), 27 | }); 28 | -------------------------------------------------------------------------------- /src/lib/server/db/tenant/index.ts: -------------------------------------------------------------------------------- 1 | import * as tenantSchema from "./schema"; 2 | import * as tenantRelations from "./relations"; 3 | import type { LibSQLDatabase } from "drizzle-orm/libsql"; 4 | 5 | export const tenantTSchema = { ...tenantSchema, ...tenantRelations }; 6 | 7 | export type TenantDbType = LibSQLDatabase; 8 | -------------------------------------------------------------------------------- /src/lib/server/db/tenant/relations.ts: -------------------------------------------------------------------------------- 1 | import { relations } from "drizzle-orm"; 2 | import { tasks, users } from "./schema"; 3 | 4 | export const userRelations = relations(users, ({ many }) => ({ 5 | tasks: many(tasks), 6 | })); 7 | 8 | export const taskRelations = relations(tasks, ({ one }) => ({ 9 | createdBy: one(users, { 10 | fields: [tasks.createdBy], 11 | references: [users.id], 12 | }), 13 | })); 14 | -------------------------------------------------------------------------------- /src/lib/server/db/tenant/schema.ts: -------------------------------------------------------------------------------- 1 | import { sql } from "drizzle-orm"; 2 | import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core"; 3 | 4 | export const users = sqliteTable("users", { 5 | id: integer("user_id").primaryKey({ autoIncrement: true }), // have to use id instead of userId for lucia auth 6 | username: text("username").unique().notNull(), 7 | password: text("password").notNull(), 8 | role: text("role", { enum: ["admin", "normal"] }).notNull(), 9 | createdAt: text("created_at") 10 | .notNull() 11 | .default(sql`current_timestamp`), 12 | }); 13 | 14 | export const sessions = sqliteTable("sessions", { 15 | id: text("session_id").notNull().primaryKey(), 16 | userId: integer("user_id") 17 | .notNull() 18 | .references(() => users.id), 19 | expiresAt: integer("expires_at").notNull(), 20 | }); 21 | 22 | export const tasks = sqliteTable("tasks", { 23 | id: integer("task_id").primaryKey({ autoIncrement: true }), 24 | title: text("title").notNull(), 25 | status: text("status").notNull(), 26 | label: text("label").notNull(), 27 | createdBy: integer("created_by") 28 | .references(() => users.id) 29 | .notNull(), 30 | priority: text("priority").notNull(), 31 | createdAt: text("created_at") 32 | .notNull() 33 | .default(sql`current_timestamp`), 34 | }); 35 | -------------------------------------------------------------------------------- /src/lib/server/utils/fluff.ts: -------------------------------------------------------------------------------- 1 | export async function delay(ms: number = 1000) { 2 | return new Promise((resolve) => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/server/utils/getTenantInformation.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { centralDb } from "../db/central"; 3 | import { tenants, customDomains } from "../db/central/schema"; 4 | import { getTenantDbClient } from "../utils/init-db"; 5 | import { getDomainAndType } from "../../util"; 6 | 7 | export async function getTenant(host: string) { 8 | const { domain, type } = getDomainAndType(host); 9 | 10 | if (type === "appDomain") return null; 11 | 12 | let databaseName: string = ""; 13 | let tenant; 14 | if (type === "subdomain") { 15 | tenant = await centralDb.query.tenants.findFirst({ 16 | where: eq(tenants.subdomain, domain.toLocaleLowerCase()), 17 | columns: { 18 | tenantId: true, 19 | name: true, 20 | subdomain: true, 21 | createdAt: true, 22 | databaseName: true, 23 | }, 24 | }); 25 | 26 | if (!tenant) return null; 27 | databaseName = tenant.databaseName; 28 | } else if (type === "customDomain") { 29 | const data = await centralDb.query.customDomains.findFirst({ 30 | where: eq(customDomains.customDomain, domain.toLocaleLowerCase()), 31 | columns: {}, 32 | with: { tenant: { columns: { databaseName: true } } }, 33 | }); 34 | 35 | if (!data) return null; 36 | databaseName = data.tenant.databaseName; 37 | tenant = await centralDb.query.tenants.findFirst({ 38 | where: eq(tenants.databaseName, databaseName), 39 | columns: { 40 | tenantId: true, 41 | name: true, 42 | subdomain: true, 43 | createdAt: true, 44 | databaseName: true, 45 | }, 46 | }); 47 | } 48 | 49 | const tenantDb = getTenantDbClient(databaseName); 50 | return { tenantDb, tenantInfo: tenant }; 51 | } 52 | -------------------------------------------------------------------------------- /src/lib/server/utils/init-db.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TURSO_GROUP_AUTH_TOKEN, 3 | TURSO_ORGANIZATION_NAME, 4 | } from "$env/static/private"; 5 | import { createClient } from "@libsql/client"; 6 | import { tenantTSchema } from "../db/tenant"; 7 | import { drizzle } from "drizzle-orm/libsql"; 8 | 9 | export const getTenantDbClient = (databaseName: string) => { 10 | const authToken = TURSO_GROUP_AUTH_TOKEN; 11 | 12 | const tenantDbUrl = makeTenantDbUrl({ 13 | dbName: databaseName, 14 | orgName: TURSO_ORGANIZATION_NAME, 15 | }); 16 | 17 | const tenantTursoClient = createClient({ url: tenantDbUrl, authToken }); 18 | const tenantDb = drizzle(tenantTursoClient, { schema: tenantTSchema }); 19 | 20 | return tenantDb; 21 | }; 22 | 23 | type MakeTenantDbUrl = { 24 | orgName: string; 25 | dbName: string; 26 | }; 27 | 28 | function makeTenantDbUrl(input: MakeTenantDbUrl) { 29 | const { orgName, dbName } = input; 30 | return `libsql://${dbName}-${orgName}.turso.io`; 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/server/utils/password-utils.ts: -------------------------------------------------------------------------------- 1 | /* src : https://lord.technology/2024/02/21/hashing-passwords-on-cloudflare-workers.html */ 2 | /* can't use bcrypt or argon2 since im planning on deploying to cf pages free plan and the cpu time limit */ 3 | /* is not enough for password hashing with those two algos */ 4 | 5 | export async function hashPassword( 6 | password: string, 7 | providedSalt?: Uint8Array 8 | ): Promise { 9 | const encoder = new TextEncoder(); 10 | // Use provided salt if available, otherwise generate a new one 11 | const salt = providedSalt || crypto.getRandomValues(new Uint8Array(16)); 12 | const keyMaterial = await crypto.subtle.importKey( 13 | "raw", 14 | encoder.encode(password), 15 | { name: "PBKDF2" }, 16 | false, 17 | ["deriveBits", "deriveKey"] 18 | ); 19 | const key = await crypto.subtle.deriveKey( 20 | { 21 | name: "PBKDF2", 22 | salt: salt, 23 | iterations: 100000, 24 | hash: "SHA-256", 25 | }, 26 | keyMaterial, 27 | { name: "AES-GCM", length: 256 }, 28 | true, 29 | ["encrypt", "decrypt"] 30 | ); 31 | const exportedKey = (await crypto.subtle.exportKey( 32 | "raw", 33 | key 34 | )) as ArrayBuffer; 35 | const hashBuffer = new Uint8Array(exportedKey); 36 | const hashArray = Array.from(hashBuffer); 37 | const hashHex = hashArray 38 | .map((b) => b.toString(16).padStart(2, "0")) 39 | .join(""); 40 | const saltHex = Array.from(salt) 41 | .map((b) => b.toString(16).padStart(2, "0")) 42 | .join(""); 43 | return `${saltHex}:${hashHex}`; 44 | } 45 | 46 | export async function verifyPassword( 47 | storedHash: string, 48 | passwordAttempt: string 49 | ): Promise { 50 | const [saltHex, originalHash] = storedHash.split(":"); 51 | const matchResult = saltHex.match(/.{1,2}/g); 52 | if (!matchResult) { 53 | throw new Error("Invalid salt format"); 54 | } 55 | const salt = new Uint8Array(matchResult.map((byte) => parseInt(byte, 16))); 56 | const attemptHashWithSalt = await hashPassword(passwordAttempt, salt); 57 | const [, attemptHash] = attemptHashWithSalt.split(":"); 58 | return attemptHash === originalHash; 59 | } 60 | 61 | export function generateRandomString(): string { 62 | return Array.from(crypto.getRandomValues(new Uint8Array(32))) 63 | .map((byte) => byte.toString(16).padStart(2, "0")) 64 | .join(""); 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/util.ts: -------------------------------------------------------------------------------- 1 | import { PUBLIC_DOMAIN } from "$env/static/public"; 2 | 3 | export const subdomainRegex = new RegExp( 4 | `(.*)\.${PUBLIC_DOMAIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}` 5 | ); 6 | 7 | interface Domain { 8 | domain: string; 9 | type: "subdomain" | "customDomain" | "appDomain"; 10 | } 11 | 12 | export function getDomainAndType(host: string): Domain { 13 | if (host === PUBLIC_DOMAIN) return { domain: host, type: "appDomain" }; 14 | 15 | const domain = host.match(subdomainRegex)?.[1]; 16 | if (domain) { 17 | return { domain, type: "subdomain" }; 18 | } 19 | 20 | return { domain: host, type: "customDomain" }; 21 | } 22 | -------------------------------------------------------------------------------- /src/routes/(central)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Miloudi Multi Tenancy Demo 11 | 12 |
15 |
18 |
21 |
22 |

Create a tenant

23 |

24 | Create your tenant with an isolated database 25 |

26 |
27 | 28 |
29 |
30 | 31 |
34 | 40 |
41 | Miloudi Multi Tenancy Demo 42 |
43 |
44 |

Key Features:

45 |
    46 |
  • 47 | 48 | Isolated Database: Each tenant gets their own database 50 | for data privacy. 52 |
  • 53 |
  • 54 | 55 | Unique Subdomain: Every tenant has their own subdomain 57 | for easy access. 59 |
  • 60 |
  • 61 | 62 | Custom Domains: Tenants can use their own domain names. 65 |
  • 66 |
  • 67 | 68 | Automatic SSL: SSL certificates are auto-issued for 70 | all domains. 72 |
  • 73 |
  • 74 | 75 | Data Portability: Tenants can download their database 77 | anytime. 79 |
  • 80 |
  • 81 | 82 | User Management: Tenant admins can create users with 84 | tenant-specific access. 86 |
  • 87 |
88 |
89 |
90 |
91 | -------------------------------------------------------------------------------- /src/routes/(central)/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fail, message, setError, superValidate } from "sveltekit-superforms"; 2 | import { zod } from "sveltekit-superforms/adapters"; 3 | import { tenantCreationSchema } from "./schema"; 4 | import type { Actions } from "@sveltejs/kit"; 5 | import { centralDb } from "$lib/server/db/central"; 6 | import { tenants } from "$lib/server/db/central/schema"; 7 | import { eq } from "drizzle-orm"; 8 | import { 9 | TURSO_GROUP_NAME, 10 | TURSO_ORGANIZATION_NAME, 11 | TURSO_PLATFORM_AUTH_TOKEN, 12 | TURSO_SCHEMA_DATABASE_NAME, 13 | } from "$env/static/private"; 14 | import { createClient } from "@tursodatabase/api"; 15 | import { hashPassword } from "$lib/server/utils/password-utils"; 16 | import { getTenantDbClient } from "$lib/server/utils/init-db"; 17 | import { users } from "$lib/server/db/tenant/schema"; 18 | import { PUBLIC_DOMAIN } from "$env/static/public"; 19 | 20 | export async function load() { 21 | const form = await superValidate(zod(tenantCreationSchema)); 22 | 23 | return { form }; 24 | } 25 | 26 | export const actions: Actions = { 27 | default: async function ({ request }) { 28 | const form = await superValidate(request, zod(tenantCreationSchema)); 29 | 30 | if (!form.valid) return fail(400, { form }); 31 | 32 | const { username, subdomain, tenantName, password } = form.data; 33 | 34 | const tenant = await centralDb.query.tenants.findFirst({ 35 | where: eq(tenants.subdomain, subdomain), 36 | columns: { tenantId: true }, 37 | }); 38 | 39 | if (tenant) { 40 | return setError(form, "subdomain", "Subdomain already exists"); 41 | } 42 | 43 | const tursoPlatform = createClient({ 44 | org: TURSO_ORGANIZATION_NAME, 45 | token: TURSO_PLATFORM_AUTH_TOKEN, 46 | }); 47 | 48 | const databaseName = `v1-${subdomain}`; 49 | const data = await tursoPlatform.databases.create(databaseName, { 50 | group: TURSO_GROUP_NAME, 51 | schema: TURSO_SCHEMA_DATABASE_NAME, 52 | }); 53 | 54 | const passwordHash = await hashPassword(password); 55 | 56 | const companyData = await centralDb.insert(tenants).values({ 57 | subdomain, 58 | name: tenantName, 59 | databaseName, 60 | }); 61 | const tenantDb = getTenantDbClient(databaseName); 62 | 63 | const userData = await tenantDb.insert(users).values({ 64 | username, 65 | password: passwordHash, 66 | role: "admin", 67 | }); 68 | return message(form, { 69 | message: "Your tenant has been successfully created", 70 | domain: `http://${subdomain}.${PUBLIC_DOMAIN}`, 71 | }); 72 | }, 73 | }; 74 | -------------------------------------------------------------------------------- /src/routes/(central)/+page.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 |
18 | {#if $message} 19 |

20 | {$message.message} your domain is : 21 | {$message.domain} 24 |

25 | {/if} 26 | 27 |
28 | 29 | 30 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 46 | 47 | Subdomain should only contain characters and hyphens
{$formData.subdomain + "." + PUBLIC_DOMAIN}
51 | 52 |
53 | 54 | 55 | 56 |
57 | 58 |
59 | 60 | 61 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 78 | 79 | 80 | 81 | 82 | {#if $delayed}{/if}Submit 86 | 87 |
88 | -------------------------------------------------------------------------------- /src/routes/(central)/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const subdomainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-_]{0,32}[a-zA-Z0-9])?$/; 4 | 5 | export const tenantCreationSchema = z.object({ 6 | tenantName: z.string().min(4), 7 | subdomain: z 8 | .string() 9 | .min(4) 10 | .max(32) 11 | .regex( 12 | subdomainRegex, 13 | "subdomain contains only alphabetic characters, numbers, and hyphens (-), and cannot end with a hyphen" 14 | ), 15 | username: z.string().min(4).max(64), 16 | password: z.string().min(8).max(32), 17 | }); 18 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | 3 | export function load({ locals }) { 4 | if (!locals.user || !locals.tenantInfo) { 5 | redirect(302, "/login"); 6 | } 7 | 8 | return { user: locals.user, tenantInfo: locals.tenantInfo }; 9 | } 10 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 |
29 |
32 | 57 | 58 | 59 | 68 | 69 | 70 | 88 | 89 | 90 |
91 |
92 | 93 | 94 | 103 | 104 | 105 | My Account 106 | 109 | 110 | { 112 | await fetch("/logout"); 113 | goto("/login"); 114 | }}>Logout 116 | 117 | 118 |
119 |
120 |
123 | 124 |
125 |
126 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | 3 | export function load() { 4 | redirect(301, "/tasks"); 5 | } 6 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/logout/+server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | 3 | export async function GET({ locals, cookies }) { 4 | const { lucia } = locals; 5 | if (!locals.session || !lucia) { 6 | redirect(301, "/login"); 7 | } 8 | 9 | await lucia.invalidateSession(locals.session.id); 10 | const sessionCookie = lucia.createBlankSessionCookie(); 11 | cookies.set(sessionCookie.name, sessionCookie.value, { 12 | path: ".", 13 | ...sessionCookie.attributes, 14 | }); 15 | redirect(301, "/login"); 16 | } 17 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/settings/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { error } from "@sveltejs/kit"; 2 | 3 | export function load({ locals }) { 4 | if (!locals.user?.role || locals.user.role !== "admin") { 5 | error(401, "Unauthorized"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/settings/+layout.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 |
19 |

Settings

20 |
21 |
24 | 39 | 40 | 41 |
42 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/settings/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | 3 | export function load() { 4 | redirect(301, "/settings/general"); 5 | } 6 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/settings/data/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | 9 | 10 | Backup data 11 | Download your database dump (libsql/sqlite), you are free to inspect 13 | it. 15 | 16 | 17 | Thanks to the architecture of this app, each tenant has its own isolated 18 | database which makes it very easy to download your own data to back it up 19 | locally or some other place. 20 | 21 | 22 | 27 | 28 | 29 |
30 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/settings/data/download-dump/+server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TURSO_GROUP_AUTH_TOKEN, 3 | TURSO_ORGANIZATION_NAME, 4 | } from "$env/static/private"; 5 | import { error } from "@sveltejs/kit"; 6 | 7 | export async function GET({ request, locals }) { 8 | if (!locals.user?.role || locals.user.role !== "admin") { 9 | error(401, "Unauthorized"); 10 | } 11 | 12 | const { databaseName } = locals.tenantInfo!; 13 | 14 | const res = await fetch( 15 | `https://${databaseName}-${TURSO_ORGANIZATION_NAME}.turso.io/dump`, 16 | { 17 | headers: { 18 | Authorization: `Bearer ${TURSO_GROUP_AUTH_TOKEN}`, 19 | }, 20 | } 21 | ); 22 | 23 | const stream = res.body; 24 | 25 | return new Response(stream, { 26 | status: 200, 27 | headers: { 28 | "Content-Disposition": "attachment", 29 | filename: databaseName + ".sql", 30 | }, 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/settings/domains/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fail, message, setError, superValidate } from "sveltekit-superforms"; 2 | import { zod } from "sveltekit-superforms/adapters"; 3 | import { customDomainSchema } from "./schema.js"; 4 | import { centralDb } from "$lib/server/db/central/index.js"; 5 | import { customDomains, tenants } from "$lib/server/db/central/schema.js"; 6 | import { and, eq } from "drizzle-orm"; 7 | import { error } from "@sveltejs/kit"; 8 | import { CLOUDFLARE_ZONE_ID, CLOUDFLARE_TOKEN } from "$env/static/private"; 9 | import { PUBLIC_DOMAIN } from "$env/static/public"; 10 | 11 | async function getDomainInfo(tenantId: number) { 12 | const [domainInfo] = await centralDb 13 | .select({ 14 | subdomain: tenants.subdomain, 15 | customDomain: customDomains.customDomain, 16 | customDomainCfId: customDomains.cloudflareHostnameId, 17 | customDomainVerified: customDomains.verified, 18 | }) 19 | .from(tenants) 20 | .leftJoin(customDomains, eq(tenants.tenantId, customDomains.tenantId)) 21 | .where(eq(tenants.tenantId, tenantId)) 22 | .limit(1); 23 | return domainInfo; 24 | } 25 | 26 | async function checkDomainStatusCF( 27 | domainCfId: string | null 28 | ): Promise { 29 | if (!domainCfId) return []; 30 | const res = await fetch( 31 | `https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/custom_hostnames/${domainCfId}`, 32 | { 33 | method: "PATCH", 34 | headers: { 35 | "Content-Type": "application/json", 36 | Authorization: `Bearer ${CLOUDFLARE_TOKEN}`, 37 | }, 38 | body: JSON.stringify({ 39 | ssl: { 40 | method: "http", 41 | settings: { 42 | min_tls_version: "1.0", 43 | }, 44 | type: "dv", 45 | }, 46 | }), 47 | } 48 | ); 49 | 50 | const errors: string[] = []; 51 | const result = (await res.json()) as Record; 52 | const verificationErrors = 53 | result.result.verification_errors || ([] as string[]); 54 | if ( 55 | verificationErrors.length > 0 && 56 | verificationErrors[0].includes("CNAME") 57 | ) { 58 | errors.push(`Your domain does not CNAME to ${PUBLIC_DOMAIN}`); 59 | } 60 | 61 | if (errors.length == 0 && result.result.status == "pending") { 62 | errors.push( 63 | "Your domain's SSL certificate installation is pending, this shouldn't take a long time" 64 | ); 65 | } 66 | return errors; 67 | } 68 | 69 | export async function load({ locals }) { 70 | // maybe stream domain Info and domain errors 71 | const domainInfo = await getDomainInfo(locals.tenantInfo?.tenantId!); 72 | const form = await superValidate(zod(customDomainSchema)); 73 | const domainErrors = await checkDomainStatusCF(domainInfo.customDomainCfId); 74 | return { form, domainInfo, domainErrors }; 75 | } 76 | 77 | export const actions = { 78 | setCustomDomain: async ({ request, locals }) => { 79 | if (!locals.user?.role || locals.user.role !== "admin") { 80 | error(401, "Unauthorized"); 81 | } 82 | 83 | const domainInfo = await getDomainInfo(locals.tenantInfo?.tenantId!); 84 | if (domainInfo.customDomain) { 85 | return fail(400, { 86 | form: { 87 | errors: [{ message: "Custom domain already set" }], 88 | }, 89 | }); 90 | } 91 | 92 | const form = await superValidate(request, zod(customDomainSchema)); 93 | 94 | if (!form.valid) return fail(400, { form }); 95 | 96 | const res = await fetch( 97 | `https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/custom_hostnames`, 98 | { 99 | method: "post", 100 | headers: { 101 | "Content-Type": "application/json", 102 | Authorization: `Bearer ${CLOUDFLARE_TOKEN}`, 103 | }, 104 | body: JSON.stringify({ 105 | hostname: form.data.host, 106 | ssl: { 107 | method: "http", 108 | settings: { 109 | min_tls_version: "1.0", 110 | }, 111 | type: "dv", 112 | }, 113 | }), 114 | } 115 | ); 116 | 117 | const result = (await res.json()) as Record; 118 | const domainCfId = result.result.id as string; 119 | if (!res.ok || !domainCfId) { 120 | setError(form, "host", "failed to add custom domain"); 121 | console.log(result); 122 | return fail(500, { form }); 123 | } 124 | 125 | await centralDb.insert(customDomains).values({ 126 | tenantId: locals.tenantInfo?.tenantId!, 127 | customDomain: form.data.host, 128 | cloudflareHostnameId: domainCfId, 129 | }); 130 | 131 | return message( 132 | form, 133 | "Added custom domain successfully, please wait for DNS to propagate and SSL to be issued" 134 | ); 135 | }, 136 | deleteCustomDomain: async ({ locals }) => { 137 | if (!locals.user?.role || locals.user.role !== "admin") { 138 | error(401, "Unauthorized"); 139 | } 140 | 141 | const domainInfo = await getDomainInfo(locals.tenantInfo?.tenantId!); 142 | 143 | if (!domainInfo.customDomain || !domainInfo.customDomainCfId) return; 144 | const res = await fetch( 145 | `https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/custom_hostnames/${domainInfo.customDomainCfId}`, 146 | { 147 | method: "delete", 148 | headers: { 149 | "Content-Type": "application/json", 150 | Authorization: `Bearer ${CLOUDFLARE_TOKEN}`, 151 | }, 152 | } 153 | ); 154 | 155 | if (!res.ok) return; 156 | 157 | await centralDb 158 | .delete(customDomains) 159 | .where( 160 | and( 161 | eq(customDomains.tenantId, locals.tenantInfo?.tenantId!), 162 | eq(customDomains.cloudflareHostnameId, domainInfo.customDomainCfId) 163 | ) 164 | ); 165 | }, 166 | }; 167 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/settings/domains/+page.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 |
25 | 26 | 27 | Tenant Subdomain 28 | Your own subdomain. 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 | 38 | 39 | Custom Domain 40 | Your own custom domain. 41 | 42 | 43 | {#if data.domainInfo.customDomain} 44 | 45 | 46 | Custom Domain 47 | 48 | 49 | 50 | {:else} 51 | 52 | 53 | Custom Domain 54 | 55 | 56 | Change your custom domain's DNS 58 |
59 | CNAME 60 | {$formData.host || ""} 63 | {PUBLIC_DOMAIN} 64 |
65 |
66 | 67 |
68 | {/if} 69 | 70 | {#if data.domainErrors.length > 0} 71 |

{data.domainErrors.join(",")}

72 | {/if} 73 |
74 | 75 | 78 | {#if $delayed}{/if}Save 82 | {#if Boolean(data.domainInfo.customDomain)} 83 | 84 | Delete 85 | 86 | {/if} 87 | {#if data.domainErrors.length > 0} 88 | await invalidateAll()} 89 | >Re-Check Domain 91 | {/if} 92 | {#if $message} 93 |

{$message}

94 | {/if} 95 |
96 |
97 | 98 |
99 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/settings/domains/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const customDomainSchema = z.object({ 4 | host: z 5 | .string() 6 | .min(4) 7 | .includes(".") 8 | .max(255) 9 | .refine( 10 | (host) => 11 | !host.includes(":") && !host.includes("/") && !host.includes(" "), 12 | "Must not include protocols, ':' or '/'" 13 | ), 14 | }); 15 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/settings/general/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fail, message, superValidate } from "sveltekit-superforms"; 2 | import { zod } from "sveltekit-superforms/adapters"; 3 | import { tenantNameSchema } from "./schema.js"; 4 | import { centralDb } from "$lib/server/db/central/index.js"; 5 | import { tenants } from "$lib/server/db/central/schema.js"; 6 | import { eq } from "drizzle-orm"; 7 | import { error } from "@sveltejs/kit"; 8 | 9 | export async function load({ locals }) { 10 | const tenantName = locals.tenantInfo?.name; 11 | const form = await superValidate({ tenantName }, zod(tenantNameSchema)); 12 | return { form }; 13 | } 14 | 15 | export const actions = { 16 | changeTenantName: async ({ request, locals }) => { 17 | if (!locals.user?.role || locals.user.role !== "admin") { 18 | error(401, "Unauthorized"); 19 | } 20 | const form = await superValidate(request, zod(tenantNameSchema)); 21 | 22 | if (!form.valid) return fail(400, { form }); 23 | 24 | await centralDb 25 | .update(tenants) 26 | .set({ name: form.data.tenantName }) 27 | .where(eq(tenants.tenantId, locals.tenantInfo?.tenantId!)); 28 | 29 | return message(form, "Tenant name updated successfully"); 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/settings/general/+page.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 |
17 |
18 | 19 | 20 | Tenant Name 21 | Your business/company name. 22 | 23 | 24 | 25 | 26 | Tenant Name 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {#if $delayed}{/if}Save 38 | {#if $message} 39 |

Updated.

40 | {/if} 41 |
42 |
43 |
44 |
45 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/settings/general/schema.ts: -------------------------------------------------------------------------------- 1 | import { tenantCreationSchema } from "../../../../../../(central)/schema"; 2 | 3 | export const tenantNameSchema = tenantCreationSchema.pick({ tenantName: true }); 4 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/settings/users/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fail, message, setError, superValidate } from "sveltekit-superforms"; 2 | import { zod } from "sveltekit-superforms/adapters"; 3 | import { createUserSchema } from "./schema.js"; 4 | import { error } from "@sveltejs/kit"; 5 | import { hashPassword } from "$lib/server/utils/password-utils.js"; 6 | import { users } from "$lib/server/db/tenant/schema.js"; 7 | 8 | export async function load() { 9 | const form = await superValidate(zod(createUserSchema)); 10 | return { form }; 11 | } 12 | 13 | export const actions = { 14 | createUser: async ({ request, locals }) => { 15 | if (!locals.user?.role || locals.user.role !== "admin") { 16 | error(401, "Unauthorized"); 17 | } 18 | const form = await superValidate(request, zod(createUserSchema)); 19 | 20 | if (!form.valid) return fail(400, { form }); 21 | 22 | const { username, password, role } = form.data; 23 | 24 | const passwordHash = await hashPassword(password); 25 | 26 | const tenantDb = locals.tenantDb!; 27 | 28 | try { 29 | await tenantDb.insert(users).values({ 30 | username, 31 | password: passwordHash, 32 | role, 33 | }); 34 | } catch (err) { 35 | // TODO, narrow error to libsql error and see if it's really foreign key constraint violation 36 | setError(form, "username", "Username already exists"); 37 | return fail(400, { form }); 38 | } 39 | return message(form, "User Created Successfully"); 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/settings/users/+page.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 |
17 |
18 | 19 | 20 | Create User 21 | Create user for this tenant, this user will not be able to access 23 | other tenants 25 | 26 | 27 | 28 | 29 | Username 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | User password 38 | 39 | 40 | 41 | 42 | 43 | 44 | User Role 45 | 49 |
50 | 51 | 52 | Normal 53 | 54 |
55 |
56 | 57 | 58 | Administrator 59 | 60 |
61 | 62 |
63 | 64 |
65 |
66 | 67 | 68 | {#if $delayed}{/if}Save 72 | {#if $message} 73 |

{$message}

74 | {/if} 75 |
76 |
77 |
78 |
79 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/settings/users/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { tenantCreationSchema } from "../../../../../../(central)/schema"; 3 | 4 | export const createUserSchema = tenantCreationSchema 5 | .pick({ 6 | username: true, 7 | password: true, 8 | }) 9 | .extend({ 10 | role: z.enum(["normal", "admin"]), 11 | }); 12 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/data-table-checkbox.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/data-table-column-header.svelte: -------------------------------------------------------------------------------- 1 | 56 | 57 | {#if !props.sort.disabled} 58 |
59 | 60 | 61 | 76 | 77 | 78 | 79 | 80 | Asc 81 | 82 | 83 | 84 | Desc 85 | 86 | 87 | 88 | 89 | Hide 90 | 91 | 92 | 93 |
94 | {:else} 95 | 96 | {/if} 97 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/data-table-create-task.svelte: -------------------------------------------------------------------------------- 1 | 60 | 61 | 62 | 71 | 72 | 73 | Create a new task 74 | 75 | Fill in the form below to create a new task. 76 | {#if $message} 77 |

78 | Your task has been created successfully. You can continue to create 79 | other tasks below. 80 |

81 | {/if} 82 |
83 |
84 | 85 |
86 | 87 | 88 | Title 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | Label 97 | { 100 | v && ($formData.label = v.value); 101 | }} 102 | > 103 | 104 | 105 | 106 | 107 | {#each labels as label} 108 | 109 | {/each} 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | Status 120 | { 123 | v && ($formData.status = v.value); 124 | }} 125 | > 126 | 127 | 128 | 129 | 130 | {#each statuses as status} 131 | 132 | {/each} 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | Priority 143 | { 146 | v && ($formData.priority = v.value); 147 | }} 148 | > 149 | 150 | 151 | 152 | 153 | {#each priorities as priority} 154 | 155 | {/each} 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | {#if $delayed}{/if}Save Task 168 |
169 |
170 |
171 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/data-table-date-cell.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | {#if value} 13 |
14 |
{date}
15 |
{time}
16 |
17 | {/if} 18 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/data-table-faceted-filter.svelte: -------------------------------------------------------------------------------- 1 | 35 | 36 | 37 | 38 | 70 | 71 | 72 | 73 | 74 | 75 | No results found. 76 | 77 | {#each options as option} 78 | {@const Icon = option.icon} 79 | { 82 | handleSelect(currentValue); 83 | }} 84 | > 85 |
93 | 94 |
95 | 96 | 97 | {option.label} 98 | 99 | {#if counts[option.value]} 100 | 103 | {counts[option.value]} 104 | 105 | {/if} 106 |
107 | {/each} 108 |
109 | {#if filterValues.length > 0} 110 | 111 | { 114 | filterValues = []; 115 | }} 116 | > 117 | Clear filters 118 | 119 | {/if} 120 |
121 |
122 |
123 |
124 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/data-table-pagination.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 |
22 |
23 | {Object.keys($selectedDataIds).length} of {$rows.length} row(s) selected. 24 |
25 |
26 |
27 |

Rows per page

28 | pageSize.set(Number(selected?.value))} 30 | selected={{ value: 10, label: "10" }} 31 | > 32 | 33 | 34 | 35 | 36 | 10 37 | 20 38 | 30 39 | 40 40 | 50 41 | 42 | 43 |
44 |
45 |
48 | Page {$pageIndex + 1} of {$pageCount} 49 |
50 |
51 | 60 | 69 | 78 | 88 |
89 |
90 |
91 |
92 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/data-table-priority-cell.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | {#if priority} 9 |
10 | {#if Icon} 11 | 12 | {/if} 13 | {priority.label} 14 |
15 | {/if} 16 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/data-table-row-actions.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 22 | 23 | 24 | Edit 25 | Make a copy 26 | Favorite 27 | 28 | 29 | Labels 30 | 31 | 32 | {#each labels as label} 33 | 34 | {label.label} 35 | 36 | {/each} 37 | 38 | 39 | 40 | 41 | 42 | Delete 43 | ⌘⌫ 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/data-table-status-cell.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | {#if status} 10 |
11 | {#if Icon} 12 | 13 | {/if} 14 | {status.label} 15 |
16 | {/if} 17 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/data-table-title-cell.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 | {#if label} 12 | {label.label} 13 | {/if} 14 | 15 | {value} 16 | 17 |
18 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/data-table-toolbar.svelte: -------------------------------------------------------------------------------- 1 | 52 | 53 |
54 |
55 | 61 | 62 | 68 | 74 | {#if showReset} 75 | 87 | {/if} 88 |
89 | 90 |
91 | 92 | 93 |
94 |
95 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/data-table-view-options.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 26 | 35 | 36 | 37 | Toggle columns 38 | 39 | {#each flatColumns as col} 40 | {#if hidableCols.includes(col.id)} 41 | handleHide(col.id)} 44 | > 45 | {col.header} 46 | 47 | {/if} 48 | {/each} 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/data-table.svelte: -------------------------------------------------------------------------------- 1 | 186 | 187 |
188 | 189 |
190 | 191 | 192 | {#each $headerRows as headerRow} 193 | 194 | 195 | {#each headerRow.cells as cell (cell.id)} 196 | 202 | 203 | {#if cell.id !== "select" && cell.id !== "actions"} 204 | 209 | 211 | {:else} 212 | 213 | {/if} 214 | 215 | 216 | {/each} 217 | 218 | 219 | {/each} 220 | 221 | 222 | {#if $pageRows.length} 223 | {#each $pageRows as row (row.id)} 224 | 225 | 226 | {#each row.cells as cell (cell.id)} 227 | 228 | 229 | {#if cell.id === "task"} 230 |
231 | 232 |
233 | {:else} 234 | 235 | {/if} 236 |
237 |
238 | {/each} 239 |
240 |
241 | {/each} 242 | {:else} 243 | 244 | 245 | No results. 246 | 247 | 248 | {/if} 249 |
250 |
251 |
252 | 253 |
254 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DataTableCheckbox } from "./data-table-checkbox.svelte"; 2 | export { default as DataTableTitleCell } from "./data-table-title-cell.svelte"; 3 | export { default as DataTableStatusCell } from "./data-table-status-cell.svelte"; 4 | export { default as DataTableRowActions } from "./data-table-row-actions.svelte"; 5 | export { default as DataTablePriorityCell } from "./data-table-priority-cell.svelte"; 6 | export { default as DataTableColumnHeader } from "./data-table-column-header.svelte"; 7 | export { default as DataTableToolbar } from "./data-table-toolbar.svelte"; 8 | export { default as DataTablePagination } from "./data-table-pagination.svelte"; 9 | export { default as DataTableViewOptions } from "./data-table-view-options.svelte"; 10 | export { default as DataTableFacetedFilter } from "./data-table-faceted-filter.svelte"; 11 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(components)/user-nav.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 22 | 23 | 24 | 25 |
26 |

{user.username}

27 |

28 | {user.role} 29 |

30 |
31 |
32 | 33 | 48 | 49 | { 51 | await fetch("/logout"); 52 | goto("/login"); 53 | }}>Log out 55 |
56 |
57 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(data)/data.ts: -------------------------------------------------------------------------------- 1 | import ArrowDown from "svelte-radix/ArrowDown.svelte"; 2 | import ArrowRight from "svelte-radix/ArrowRight.svelte"; 3 | import ArrowUp from "svelte-radix/ArrowUp.svelte"; 4 | import CheckCircled from "svelte-radix/CheckCircled.svelte"; 5 | import Circle from "svelte-radix/Circle.svelte"; 6 | import CrossCircled from "svelte-radix/CrossCircled.svelte"; 7 | import QuestionMarkCircled from "svelte-radix/QuestionMarkCircled.svelte"; 8 | import Stopwatch from "svelte-radix/Stopwatch.svelte"; 9 | 10 | export const labels = [ 11 | { 12 | value: "bug", 13 | label: "Bug", 14 | }, 15 | { 16 | value: "feature", 17 | label: "Feature", 18 | }, 19 | { 20 | value: "documentation", 21 | label: "Documentation", 22 | }, 23 | ] as const; 24 | 25 | export const statuses = [ 26 | { 27 | value: "backlog", 28 | label: "Backlog", 29 | icon: QuestionMarkCircled, 30 | }, 31 | { 32 | value: "todo", 33 | label: "Todo", 34 | icon: Circle, 35 | }, 36 | { 37 | value: "in progress", 38 | label: "In Progress", 39 | icon: Stopwatch, 40 | }, 41 | { 42 | value: "done", 43 | label: "Done", 44 | icon: CheckCircled, 45 | }, 46 | { 47 | value: "canceled", 48 | label: "Canceled", 49 | icon: CrossCircled, 50 | }, 51 | ] as const; 52 | 53 | export const priorities = [ 54 | { 55 | label: "Low", 56 | value: "low", 57 | icon: ArrowDown, 58 | }, 59 | { 60 | label: "Medium", 61 | value: "medium", 62 | icon: ArrowRight, 63 | }, 64 | { 65 | label: "High", 66 | value: "high", 67 | icon: ArrowUp, 68 | }, 69 | ] as const; 70 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/(data)/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const taskSchema = z.object({ 4 | id: z.number(), 5 | title: z.string(), 6 | status: z.string(), 7 | label: z.string(), 8 | createdBy: z 9 | .object({ 10 | id: z.number(), 11 | username: z.string(), 12 | }) 13 | .optional(), 14 | priority: z.string(), 15 | createdAt: z.string(), 16 | }); 17 | 18 | export const createTaskSchema = z.object({ 19 | title: z.string().min(3), 20 | status: z.string().min(1, "Status is required"), 21 | label: z.string().min(1, "Label is required"), 22 | priority: z.string().min(1, "Priority is required"), 23 | }); 24 | 25 | export type CreateTask = z.infer; 26 | export type Task = z.infer; 27 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fail, message, superValidate } from "sveltekit-superforms"; 2 | import { zod } from "sveltekit-superforms/adapters"; 3 | import { createTaskSchema } from "./(data)/schemas.js"; 4 | import { tasks } from "$lib/server/db/tenant/schema.js"; 5 | 6 | export async function load({ locals, parent }) { 7 | await parent(); 8 | const form = await superValidate(zod(createTaskSchema)); 9 | const { tenantDb } = locals; 10 | const tasks = await tenantDb!.query.tasks.findMany({ 11 | with: { createdBy: { columns: { username: true, id: true } } }, 12 | }); 13 | 14 | return { tasks, form }; 15 | } 16 | 17 | export const actions = { 18 | default: async function ({ request, locals }) { 19 | const { tenantDb, user } = locals; 20 | const form = await superValidate(request, zod(createTaskSchema)); 21 | 22 | if (!form.valid) return fail(400, { form }); 23 | 24 | const { title, status, label, priority } = form.data; 25 | 26 | const task = await tenantDb! 27 | .insert(tasks) 28 | .values({ 29 | title, 30 | status, 31 | label, 32 | priority, 33 | createdBy: user!.id, 34 | }) 35 | .returning(); 36 | return message(form, { task }); 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/+page.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
13 |
14 |
15 |

Welcome back!

16 |

17 | Here's a list of your tasks for this month! 18 |

19 |
20 |
21 | 22 |
23 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(authenticated)/tasks/tasksStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | import type { Task } from "./(data)/schemas"; 3 | 4 | export const tasks = writable([]); 5 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(guest)/login/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { users } from "$lib/server/db/tenant/schema"; 2 | import { verifyPassword } from "$lib/server/utils/password-utils"; 3 | import { error, redirect, type Actions } from "@sveltejs/kit"; 4 | import { eq } from "drizzle-orm"; 5 | import { fail, setError, superValidate } from "sveltekit-superforms"; 6 | import { zod } from "sveltekit-superforms/adapters"; 7 | import { loginSchema } from "./schema"; 8 | import { delay } from "$lib/server/utils/fluff"; 9 | 10 | export async function load() { 11 | const form = await superValidate(zod(loginSchema)); 12 | 13 | return { form }; 14 | } 15 | 16 | export const actions: Actions = { 17 | default: async function ({ request, locals, cookies, url }) { 18 | const form = await superValidate(request, zod(loginSchema)); 19 | 20 | if (!form.valid) return fail(400, { form }); 21 | 22 | const { username, password } = form.data; 23 | 24 | const { tenantDb, lucia } = locals; 25 | if (!tenantDb || !lucia) { 26 | error(500, "This app (sub)domain is invalid"); 27 | } 28 | const existingUser = await tenantDb.query.users.findFirst({ 29 | where: eq(users.username, username), 30 | columns: { id: true, username: true, password: true }, 31 | }); 32 | 33 | if ( 34 | !existingUser || 35 | !(await verifyPassword(existingUser.password, password)) 36 | ) { 37 | return setError(form, "", "Username or password incorrect"); 38 | } 39 | 40 | const session = await lucia.createSession(existingUser.id, {}); 41 | const sessionCookie = lucia.createSessionCookie(session.id); 42 | cookies.set(sessionCookie.name, sessionCookie.value, { 43 | path: ".", 44 | ...sessionCookie.attributes, 45 | }); 46 | 47 | await delay(2000); 48 | redirect(302, "/"); 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(guest)/login/+page.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | Login 18 | 19 | {#if $errors?._errors} 20 |

{$errors?._errors.join(",")}

21 | {/if} 22 |

23 |
24 |
25 | 26 |
27 | 28 | 29 | Username 30 | 31 | 32 | Your tenant's instance's admin username 35 | 36 | 37 | 38 | 39 | 40 | Password 41 | 42 | 43 | 44 | 45 | 46 | 47 | {#if $delayed}{/if}Submit 51 |
52 |
53 | 54 |
55 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/(guest)/login/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const loginSchema = z.object({ 4 | username: z.string().min(4).max(64), 5 | password: z.string().min(8).max(32), 6 | }); 7 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/+layout.server.ts: -------------------------------------------------------------------------------- 1 | export function load({ locals }) { 2 | return { tenantInfo: locals.tenantInfo! }; 3 | } 4 | -------------------------------------------------------------------------------- /src/routes/(tenant)/tenant/[subdomain]/+layout.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | {data.tenantInfo.name} 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricin9/sveltekit-multi-tenancy/2543d6753f638bb57322942a21bde9b098f997e5/static/favicon.png -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-cloudflare-workers"; 2 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter(), 15 | alias: { 16 | $ui: "./src/lib/client/components/ui", 17 | }, 18 | }, 19 | }; 20 | 21 | export default config; 22 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { fontFamily } from "tailwindcss/defaultTheme"; 2 | import type { Config } from "tailwindcss"; 3 | 4 | const config: Config = { 5 | darkMode: ["class"], 6 | content: ["./src/**/*.{html,js,svelte,ts}"], 7 | safelist: ["dark"], 8 | theme: { 9 | container: { 10 | center: true, 11 | padding: "2rem", 12 | screens: { 13 | "2xl": "1400px" 14 | } 15 | }, 16 | extend: { 17 | colors: { 18 | border: "hsl(var(--border) / )", 19 | input: "hsl(var(--input) / )", 20 | ring: "hsl(var(--ring) / )", 21 | background: "hsl(var(--background) / )", 22 | foreground: "hsl(var(--foreground) / )", 23 | primary: { 24 | DEFAULT: "hsl(var(--primary) / )", 25 | foreground: "hsl(var(--primary-foreground) / )" 26 | }, 27 | secondary: { 28 | DEFAULT: "hsl(var(--secondary) / )", 29 | foreground: "hsl(var(--secondary-foreground) / )" 30 | }, 31 | destructive: { 32 | DEFAULT: "hsl(var(--destructive) / )", 33 | foreground: "hsl(var(--destructive-foreground) / )" 34 | }, 35 | muted: { 36 | DEFAULT: "hsl(var(--muted) / )", 37 | foreground: "hsl(var(--muted-foreground) / )" 38 | }, 39 | accent: { 40 | DEFAULT: "hsl(var(--accent) / )", 41 | foreground: "hsl(var(--accent-foreground) / )" 42 | }, 43 | popover: { 44 | DEFAULT: "hsl(var(--popover) / )", 45 | foreground: "hsl(var(--popover-foreground) / )" 46 | }, 47 | card: { 48 | DEFAULT: "hsl(var(--card) / )", 49 | foreground: "hsl(var(--card-foreground) / )" 50 | } 51 | }, 52 | borderRadius: { 53 | lg: "var(--radius)", 54 | md: "calc(var(--radius) - 2px)", 55 | sm: "calc(var(--radius) - 4px)" 56 | }, 57 | fontFamily: { 58 | sans: [...fontFamily.sans] 59 | } 60 | } 61 | }, 62 | }; 63 | 64 | export default config; 65 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler", 13 | "types": [ 14 | "@cloudflare/workers-types/2023-07-01" 15 | ] 16 | } 17 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 18 | // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files 19 | // 20 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 21 | // from the referenced tsconfig.json - TypeScript does not merge them in 22 | } 23 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()] 6 | }); 7 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | name = "sveltekit-multi-tenancy" 3 | compatibility_date = "2024-09-03" 4 | # pages_build_output_dir = ".svelte-kit/cloudflare" 5 | 6 | # https://kit.svelte.dev/docs/adapter-cloudflare-workers#basic-configuration 7 | main = "./.cloudflare/worker.js" 8 | site.bucket = "./.cloudflare/public" 9 | 10 | build.command = "pnpm run build" 11 | --------------------------------------------------------------------------------