├── .env.example ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── README.md ├── components.json ├── drizzle.config.ts ├── drizzle ├── 0000_aromatic_red_wolf.sql ├── 0001_cuddly_red_skull.sql ├── 0002_tiny_power_man.sql ├── 0003_slimy_namora.sql ├── 0004_next_jamie_braddock.sql ├── 0005_married_veda.sql ├── 0006_faithful_kree.sql ├── 0007_reflective_tomas.sql ├── 0008_outgoing_junta.sql └── meta │ ├── 0000_snapshot.json │ ├── 0001_snapshot.json │ ├── 0002_snapshot.json │ ├── 0003_snapshot.json │ ├── 0004_snapshot.json │ ├── 0005_snapshot.json │ ├── 0006_snapshot.json │ ├── 0007_snapshot.json │ ├── 0008_snapshot.json │ └── _journal.json ├── package-lock.json ├── package.json ├── postcss.config.js ├── src ├── app.css ├── app.d.ts ├── app.html ├── hooks.server.ts ├── lib │ ├── components │ │ ├── partials │ │ │ ├── avatar-dropdown.svelte │ │ │ ├── breadcrumb.svelte │ │ │ ├── crud-table.svelte │ │ │ ├── mobile-sidebar.svelte │ │ │ ├── responsive-drawer.svelte │ │ │ ├── sidebar.svelte │ │ │ ├── team-select.svelte │ │ │ └── user-agent.svelte │ │ └── ui │ │ │ ├── avatar │ │ │ ├── avatar-fallback.svelte │ │ │ ├── avatar-image.svelte │ │ │ ├── avatar.svelte │ │ │ └── index.ts │ │ │ ├── badge │ │ │ ├── badge.svelte │ │ │ └── index.ts │ │ │ ├── breadcrumb │ │ │ ├── breadcrumb-ellipsis.svelte │ │ │ ├── breadcrumb-item.svelte │ │ │ ├── breadcrumb-link.svelte │ │ │ ├── breadcrumb-list.svelte │ │ │ ├── breadcrumb-page.svelte │ │ │ ├── breadcrumb-separator.svelte │ │ │ ├── breadcrumb.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 │ │ │ ├── dialog │ │ │ ├── dialog-content.svelte │ │ │ ├── dialog-description.svelte │ │ │ ├── dialog-footer.svelte │ │ │ ├── dialog-header.svelte │ │ │ ├── dialog-overlay.svelte │ │ │ ├── dialog-portal.svelte │ │ │ ├── dialog-title.svelte │ │ │ └── index.ts │ │ │ ├── drawer │ │ │ ├── drawer-content.svelte │ │ │ ├── drawer-description.svelte │ │ │ ├── drawer-footer.svelte │ │ │ ├── drawer-header.svelte │ │ │ ├── drawer-nested.svelte │ │ │ ├── drawer-overlay.svelte │ │ │ ├── drawer-title.svelte │ │ │ ├── drawer.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 │ │ │ ├── input │ │ │ ├── index.ts │ │ │ └── input.svelte │ │ │ ├── label │ │ │ ├── index.ts │ │ │ └── label.svelte │ │ │ ├── pagination │ │ │ ├── index.ts │ │ │ ├── pagination-content.svelte │ │ │ ├── pagination-ellipsis.svelte │ │ │ ├── pagination-item.svelte │ │ │ ├── pagination-link.svelte │ │ │ ├── pagination-next-button.svelte │ │ │ ├── pagination-prev-button.svelte │ │ │ └── pagination.svelte │ │ │ ├── popover │ │ │ ├── index.ts │ │ │ └── popover-content.svelte │ │ │ ├── select │ │ │ ├── index.ts │ │ │ ├── select-content.svelte │ │ │ ├── select-item.svelte │ │ │ ├── select-label.svelte │ │ │ ├── select-separator.svelte │ │ │ └── select-trigger.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 │ │ │ ├── skeleton │ │ │ ├── index.ts │ │ │ └── skeleton.svelte │ │ │ ├── sonner │ │ │ ├── index.ts │ │ │ └── sonner.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 │ │ │ ├── tabs │ │ │ ├── index.ts │ │ │ ├── tabs-content.svelte │ │ │ ├── tabs-list.svelte │ │ │ └── tabs-trigger.svelte │ │ │ └── tooltip │ │ │ ├── index.ts │ │ │ └── tooltip-content.svelte │ ├── server │ │ ├── auth │ │ │ ├── adapter.ts │ │ │ ├── dtos.ts │ │ │ ├── repository.ts │ │ │ ├── roles.ts │ │ │ └── services │ │ │ │ └── oauth-service.ts │ │ ├── cache │ │ │ └── adapter.ts │ │ ├── database │ │ │ ├── adapter.ts │ │ │ └── schema.ts │ │ ├── email │ │ │ ├── queues.ts │ │ │ ├── service.ts │ │ │ └── workers.ts │ │ └── teams │ │ │ ├── dtos.ts │ │ │ ├── repository.ts │ │ │ └── roles.ts │ └── utils.ts └── routes │ ├── (dashboard) │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── admin │ │ └── users │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── [id] │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── session │ │ │ └── +page.server.ts │ ├── home │ │ └── +page.svelte │ ├── settings │ │ └── +page.svelte │ └── teams │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ └── [id] │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── +layout.svelte │ ├── +page.server.ts │ └── auth │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── login │ ├── +page.server.ts │ └── +page.svelte │ ├── logout │ └── +page.server.ts │ ├── oauth │ ├── +page.server.ts │ └── callback │ │ └── +server.ts │ ├── register │ ├── +page.server.ts │ └── +page.svelte │ └── verify-email │ ├── +page.server.ts │ └── +page.svelte ├── static ├── favicon.png └── img │ ├── dark-mode.png │ ├── light-mode.png │ ├── logos-chrome.png │ ├── logos-edge.png │ ├── logos-firefox.png │ ├── logos-safari.png │ ├── logos-unknown.png │ └── system-mode.png ├── svelte.config.js ├── tailwind.config.ts ├── tsconfig.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | #Connection Strings 2 | SECRET_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/boilerplate" 3 | SECRET_REDIS_URL="redis://localhost:6379" 4 | 5 | #OAuth Clients 6 | PUBLIC_OAUTH_SUPPORTED_PROVIDERS="google,twitter,..." 7 | PUBLIC_GOOGLE_CLIENT_ID="" 8 | SECRET_GOOGLE_CLIENT_KEY="" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Output 4 | .output 5 | .vercel 6 | /.svelte-kit 7 | /build 8 | 9 | # OS 10 | .DS_Store 11 | Thumbs.db 12 | 13 | # Env 14 | .env 15 | .env.* 16 | !.env.example 17 | !.env.test 18 | 19 | # Vite 20 | vite.config.js.timestamp-* 21 | vite.config.ts.timestamp-* 22 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": [ 7 | "prettier-plugin-svelte", 8 | "prettier-plugin-tailwindcss", 9 | "@trivago/prettier-plugin-sort-imports" 10 | ], 11 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }], 12 | 13 | "importOrder": ["", "(components/|./index)", "^../(.*)$"] 14 | } 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # create-svelte 2 | 3 | Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte). 4 | 5 | ## Creating a project 6 | 7 | If you're seeing this, you've probably already done this step. Congrats! 8 | 9 | ```bash 10 | # create a new project in the current directory 11 | npm create svelte@latest 12 | 13 | # create a new project in my-app 14 | npm create svelte@latest my-app 15 | ``` 16 | 17 | ## Developing 18 | 19 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 20 | 21 | ```bash 22 | npm run dev 23 | 24 | # or start the server and open the app in a new browser tab 25 | npm run dev -- --open 26 | ``` 27 | 28 | ## Building 29 | 30 | To create a production version of your app: 31 | 32 | ```bash 33 | npm run build 34 | ``` 35 | 36 | You can preview the production build with `npm run preview`. 37 | 38 | > To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. 39 | -------------------------------------------------------------------------------- /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/components", 11 | "utils": "$lib/utils" 12 | }, 13 | "typescript": true 14 | } -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'drizzle-kit'; 2 | 3 | export default defineConfig({ 4 | schema: './src/lib/server/database/schema.ts', 5 | out: './drizzle', 6 | dialect: 'postgresql', 7 | dbCredentials: { 8 | url: process.env.SECRET_DATABASE_URL! 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /drizzle/0000_aromatic_red_wolf.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "sessions" ( 2 | "id" text PRIMARY KEY NOT NULL, 3 | "user_id" integer NOT NULL, 4 | "ip" text NOT NULL, 5 | "country" text NOT NULL, 6 | "user_agent" text NOT NULL, 7 | "expires_at" timestamp with time zone NOT NULL 8 | ); 9 | --> statement-breakpoint 10 | CREATE TABLE IF NOT EXISTS "users" ( 11 | "id" serial PRIMARY KEY NOT NULL, 12 | "email" text NOT NULL, 13 | "password" text NOT NULL, 14 | "full_name" text NOT NULL, 15 | "phone" text, 16 | "country" text, 17 | "role" text DEFAULT 'user' NOT NULL, 18 | "is_verified" boolean DEFAULT false NOT NULL, 19 | "created_at" timestamp with time zone DEFAULT now(), 20 | CONSTRAINT "users_email_unique" UNIQUE("email") 21 | ); 22 | --> statement-breakpoint 23 | CREATE TABLE IF NOT EXISTS "verification_codes" ( 24 | "id" text PRIMARY KEY NOT NULL, 25 | "user_id" integer NOT NULL, 26 | "valid_until" timestamp with time zone NOT NULL, 27 | "created_at" timestamp with time zone DEFAULT now() 28 | ); 29 | --> statement-breakpoint 30 | DO $$ BEGIN 31 | ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; 32 | EXCEPTION 33 | WHEN duplicate_object THEN null; 34 | END $$; 35 | --> statement-breakpoint 36 | DO $$ BEGIN 37 | ALTER TABLE "verification_codes" ADD CONSTRAINT "verification_codes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; 38 | EXCEPTION 39 | WHEN duplicate_object THEN null; 40 | END $$; 41 | -------------------------------------------------------------------------------- /drizzle/0001_cuddly_red_skull.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "redis_connections" ( 2 | "id" serial PRIMARY KEY NOT NULL, 3 | "name" text NOT NULL, 4 | "url" text NOT NULL, 5 | "user_id" integer NOT NULL 6 | ); 7 | --> statement-breakpoint 8 | DO $$ BEGIN 9 | ALTER TABLE "redis_connections" ADD CONSTRAINT "redis_connections_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; 10 | EXCEPTION 11 | WHEN duplicate_object THEN null; 12 | END $$; 13 | -------------------------------------------------------------------------------- /drizzle/0002_tiny_power_man.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE "redis_connections"; -------------------------------------------------------------------------------- /drizzle/0003_slimy_namora.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "team_members" ( 2 | "team_id" integer NOT NULL, 3 | "user_id" integer NOT NULL, 4 | "role" text DEFAULT 'member' NOT NULL, 5 | "created_at" timestamp with time zone DEFAULT now() 6 | ); 7 | --> statement-breakpoint 8 | CREATE TABLE IF NOT EXISTS "teams" ( 9 | "id" serial PRIMARY KEY NOT NULL, 10 | "name" text NOT NULL, 11 | "created_at" timestamp with time zone DEFAULT now() 12 | ); 13 | --> statement-breakpoint 14 | DO $$ BEGIN 15 | ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE no action ON UPDATE no action; 16 | EXCEPTION 17 | WHEN duplicate_object THEN null; 18 | END $$; 19 | --> statement-breakpoint 20 | DO $$ BEGIN 21 | ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; 22 | EXCEPTION 23 | WHEN duplicate_object THEN null; 24 | END $$; 25 | -------------------------------------------------------------------------------- /drizzle/0004_next_jamie_braddock.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "users" ADD COLUMN "active_team_id" integer;--> statement-breakpoint 2 | DO $$ BEGIN 3 | ALTER TABLE "users" ADD CONSTRAINT "users_active_team_id_teams_id_fk" FOREIGN KEY ("active_team_id") REFERENCES "public"."teams"("id") ON DELETE no action ON UPDATE no action; 4 | EXCEPTION 5 | WHEN duplicate_object THEN null; 6 | END $$; 7 | -------------------------------------------------------------------------------- /drizzle/0005_married_veda.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "users" ALTER COLUMN "active_team_id" SET NOT NULL; -------------------------------------------------------------------------------- /drizzle/0006_faithful_kree.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "oauth_connections" ( 2 | "user_id" integer NOT NULL, 3 | "username" text NOT NULL, 4 | "provider_user_id" text NOT NULL, 5 | "provider" text NOT NULL, 6 | CONSTRAINT "oauth_connections_provider_user_id_user_id_pk" PRIMARY KEY("provider_user_id","user_id") 7 | ); 8 | --> statement-breakpoint 9 | ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_user_id_pk" PRIMARY KEY("team_id","user_id");--> statement-breakpoint 10 | DO $$ BEGIN 11 | ALTER TABLE "oauth_connections" ADD CONSTRAINT "oauth_connections_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; 12 | EXCEPTION 13 | WHEN duplicate_object THEN null; 14 | END $$; 15 | -------------------------------------------------------------------------------- /drizzle/0007_reflective_tomas.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "oauth_connections" RENAME COLUMN "username" TO "email"; -------------------------------------------------------------------------------- /drizzle/0008_outgoing_junta.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "team_members" DROP CONSTRAINT "team_members_team_id_teams_id_fk"; 2 | --> statement-breakpoint 3 | ALTER TABLE "team_members" DROP CONSTRAINT "team_members_user_id_users_id_fk"; 4 | --> statement-breakpoint 5 | DO $$ BEGIN 6 | ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action; 7 | EXCEPTION 8 | WHEN duplicate_object THEN null; 9 | END $$; 10 | --> statement-breakpoint 11 | DO $$ BEGIN 12 | ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; 13 | EXCEPTION 14 | WHEN duplicate_object THEN null; 15 | END $$; 16 | -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "9752a767-916a-47c7-a74e-bf6c2b75e684", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.sessions": { 8 | "name": "sessions", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "text", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "user_id": { 18 | "name": "user_id", 19 | "type": "integer", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "ip": { 24 | "name": "ip", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "country": { 30 | "name": "country", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "user_agent": { 36 | "name": "user_agent", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": true 40 | }, 41 | "expires_at": { 42 | "name": "expires_at", 43 | "type": "timestamp with time zone", 44 | "primaryKey": false, 45 | "notNull": true 46 | } 47 | }, 48 | "indexes": {}, 49 | "foreignKeys": { 50 | "sessions_user_id_users_id_fk": { 51 | "name": "sessions_user_id_users_id_fk", 52 | "tableFrom": "sessions", 53 | "tableTo": "users", 54 | "columnsFrom": [ 55 | "user_id" 56 | ], 57 | "columnsTo": [ 58 | "id" 59 | ], 60 | "onDelete": "no action", 61 | "onUpdate": "no action" 62 | } 63 | }, 64 | "compositePrimaryKeys": {}, 65 | "uniqueConstraints": {} 66 | }, 67 | "public.users": { 68 | "name": "users", 69 | "schema": "", 70 | "columns": { 71 | "id": { 72 | "name": "id", 73 | "type": "serial", 74 | "primaryKey": true, 75 | "notNull": true 76 | }, 77 | "email": { 78 | "name": "email", 79 | "type": "text", 80 | "primaryKey": false, 81 | "notNull": true 82 | }, 83 | "password": { 84 | "name": "password", 85 | "type": "text", 86 | "primaryKey": false, 87 | "notNull": true 88 | }, 89 | "full_name": { 90 | "name": "full_name", 91 | "type": "text", 92 | "primaryKey": false, 93 | "notNull": true 94 | }, 95 | "phone": { 96 | "name": "phone", 97 | "type": "text", 98 | "primaryKey": false, 99 | "notNull": false 100 | }, 101 | "country": { 102 | "name": "country", 103 | "type": "text", 104 | "primaryKey": false, 105 | "notNull": false 106 | }, 107 | "role": { 108 | "name": "role", 109 | "type": "text", 110 | "primaryKey": false, 111 | "notNull": true, 112 | "default": "'user'" 113 | }, 114 | "is_verified": { 115 | "name": "is_verified", 116 | "type": "boolean", 117 | "primaryKey": false, 118 | "notNull": true, 119 | "default": false 120 | }, 121 | "created_at": { 122 | "name": "created_at", 123 | "type": "timestamp with time zone", 124 | "primaryKey": false, 125 | "notNull": false, 126 | "default": "now()" 127 | } 128 | }, 129 | "indexes": {}, 130 | "foreignKeys": {}, 131 | "compositePrimaryKeys": {}, 132 | "uniqueConstraints": { 133 | "users_email_unique": { 134 | "name": "users_email_unique", 135 | "nullsNotDistinct": false, 136 | "columns": [ 137 | "email" 138 | ] 139 | } 140 | } 141 | }, 142 | "public.verification_codes": { 143 | "name": "verification_codes", 144 | "schema": "", 145 | "columns": { 146 | "id": { 147 | "name": "id", 148 | "type": "text", 149 | "primaryKey": true, 150 | "notNull": true 151 | }, 152 | "user_id": { 153 | "name": "user_id", 154 | "type": "integer", 155 | "primaryKey": false, 156 | "notNull": true 157 | }, 158 | "valid_until": { 159 | "name": "valid_until", 160 | "type": "timestamp with time zone", 161 | "primaryKey": false, 162 | "notNull": true 163 | }, 164 | "created_at": { 165 | "name": "created_at", 166 | "type": "timestamp with time zone", 167 | "primaryKey": false, 168 | "notNull": false, 169 | "default": "now()" 170 | } 171 | }, 172 | "indexes": {}, 173 | "foreignKeys": { 174 | "verification_codes_user_id_users_id_fk": { 175 | "name": "verification_codes_user_id_users_id_fk", 176 | "tableFrom": "verification_codes", 177 | "tableTo": "users", 178 | "columnsFrom": [ 179 | "user_id" 180 | ], 181 | "columnsTo": [ 182 | "id" 183 | ], 184 | "onDelete": "no action", 185 | "onUpdate": "no action" 186 | } 187 | }, 188 | "compositePrimaryKeys": {}, 189 | "uniqueConstraints": {} 190 | } 191 | }, 192 | "enums": {}, 193 | "schemas": {}, 194 | "_meta": { 195 | "columns": {}, 196 | "schemas": {}, 197 | "tables": {} 198 | } 199 | } -------------------------------------------------------------------------------- /drizzle/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "4d982246-385e-4f81-a04e-0c1db84caffd", 3 | "prevId": "9752a767-916a-47c7-a74e-bf6c2b75e684", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.redis_connections": { 8 | "name": "redis_connections", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "url": { 24 | "name": "url", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "user_id": { 30 | "name": "user_id", 31 | "type": "integer", 32 | "primaryKey": false, 33 | "notNull": true 34 | } 35 | }, 36 | "indexes": {}, 37 | "foreignKeys": { 38 | "redis_connections_user_id_users_id_fk": { 39 | "name": "redis_connections_user_id_users_id_fk", 40 | "tableFrom": "redis_connections", 41 | "tableTo": "users", 42 | "columnsFrom": [ 43 | "user_id" 44 | ], 45 | "columnsTo": [ 46 | "id" 47 | ], 48 | "onDelete": "cascade", 49 | "onUpdate": "no action" 50 | } 51 | }, 52 | "compositePrimaryKeys": {}, 53 | "uniqueConstraints": {} 54 | }, 55 | "public.sessions": { 56 | "name": "sessions", 57 | "schema": "", 58 | "columns": { 59 | "id": { 60 | "name": "id", 61 | "type": "text", 62 | "primaryKey": true, 63 | "notNull": true 64 | }, 65 | "user_id": { 66 | "name": "user_id", 67 | "type": "integer", 68 | "primaryKey": false, 69 | "notNull": true 70 | }, 71 | "ip": { 72 | "name": "ip", 73 | "type": "text", 74 | "primaryKey": false, 75 | "notNull": true 76 | }, 77 | "country": { 78 | "name": "country", 79 | "type": "text", 80 | "primaryKey": false, 81 | "notNull": true 82 | }, 83 | "user_agent": { 84 | "name": "user_agent", 85 | "type": "text", 86 | "primaryKey": false, 87 | "notNull": true 88 | }, 89 | "expires_at": { 90 | "name": "expires_at", 91 | "type": "timestamp with time zone", 92 | "primaryKey": false, 93 | "notNull": true 94 | } 95 | }, 96 | "indexes": {}, 97 | "foreignKeys": { 98 | "sessions_user_id_users_id_fk": { 99 | "name": "sessions_user_id_users_id_fk", 100 | "tableFrom": "sessions", 101 | "tableTo": "users", 102 | "columnsFrom": [ 103 | "user_id" 104 | ], 105 | "columnsTo": [ 106 | "id" 107 | ], 108 | "onDelete": "no action", 109 | "onUpdate": "no action" 110 | } 111 | }, 112 | "compositePrimaryKeys": {}, 113 | "uniqueConstraints": {} 114 | }, 115 | "public.users": { 116 | "name": "users", 117 | "schema": "", 118 | "columns": { 119 | "id": { 120 | "name": "id", 121 | "type": "serial", 122 | "primaryKey": true, 123 | "notNull": true 124 | }, 125 | "email": { 126 | "name": "email", 127 | "type": "text", 128 | "primaryKey": false, 129 | "notNull": true 130 | }, 131 | "password": { 132 | "name": "password", 133 | "type": "text", 134 | "primaryKey": false, 135 | "notNull": true 136 | }, 137 | "full_name": { 138 | "name": "full_name", 139 | "type": "text", 140 | "primaryKey": false, 141 | "notNull": true 142 | }, 143 | "phone": { 144 | "name": "phone", 145 | "type": "text", 146 | "primaryKey": false, 147 | "notNull": false 148 | }, 149 | "country": { 150 | "name": "country", 151 | "type": "text", 152 | "primaryKey": false, 153 | "notNull": false 154 | }, 155 | "role": { 156 | "name": "role", 157 | "type": "text", 158 | "primaryKey": false, 159 | "notNull": true, 160 | "default": "'user'" 161 | }, 162 | "is_verified": { 163 | "name": "is_verified", 164 | "type": "boolean", 165 | "primaryKey": false, 166 | "notNull": true, 167 | "default": false 168 | }, 169 | "created_at": { 170 | "name": "created_at", 171 | "type": "timestamp with time zone", 172 | "primaryKey": false, 173 | "notNull": false, 174 | "default": "now()" 175 | } 176 | }, 177 | "indexes": {}, 178 | "foreignKeys": {}, 179 | "compositePrimaryKeys": {}, 180 | "uniqueConstraints": { 181 | "users_email_unique": { 182 | "name": "users_email_unique", 183 | "nullsNotDistinct": false, 184 | "columns": [ 185 | "email" 186 | ] 187 | } 188 | } 189 | }, 190 | "public.verification_codes": { 191 | "name": "verification_codes", 192 | "schema": "", 193 | "columns": { 194 | "id": { 195 | "name": "id", 196 | "type": "text", 197 | "primaryKey": true, 198 | "notNull": true 199 | }, 200 | "user_id": { 201 | "name": "user_id", 202 | "type": "integer", 203 | "primaryKey": false, 204 | "notNull": true 205 | }, 206 | "valid_until": { 207 | "name": "valid_until", 208 | "type": "timestamp with time zone", 209 | "primaryKey": false, 210 | "notNull": true 211 | }, 212 | "created_at": { 213 | "name": "created_at", 214 | "type": "timestamp with time zone", 215 | "primaryKey": false, 216 | "notNull": false, 217 | "default": "now()" 218 | } 219 | }, 220 | "indexes": {}, 221 | "foreignKeys": { 222 | "verification_codes_user_id_users_id_fk": { 223 | "name": "verification_codes_user_id_users_id_fk", 224 | "tableFrom": "verification_codes", 225 | "tableTo": "users", 226 | "columnsFrom": [ 227 | "user_id" 228 | ], 229 | "columnsTo": [ 230 | "id" 231 | ], 232 | "onDelete": "no action", 233 | "onUpdate": "no action" 234 | } 235 | }, 236 | "compositePrimaryKeys": {}, 237 | "uniqueConstraints": {} 238 | } 239 | }, 240 | "enums": {}, 241 | "schemas": {}, 242 | "sequences": {}, 243 | "_meta": { 244 | "columns": {}, 245 | "schemas": {}, 246 | "tables": {} 247 | } 248 | } -------------------------------------------------------------------------------- /drizzle/meta/0002_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "0da8fe3d-d28b-4dac-a6bd-eb119f2281f1", 3 | "prevId": "4d982246-385e-4f81-a04e-0c1db84caffd", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.sessions": { 8 | "name": "sessions", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "text", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "user_id": { 18 | "name": "user_id", 19 | "type": "integer", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "ip": { 24 | "name": "ip", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "country": { 30 | "name": "country", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "user_agent": { 36 | "name": "user_agent", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": true 40 | }, 41 | "expires_at": { 42 | "name": "expires_at", 43 | "type": "timestamp with time zone", 44 | "primaryKey": false, 45 | "notNull": true 46 | } 47 | }, 48 | "indexes": {}, 49 | "foreignKeys": { 50 | "sessions_user_id_users_id_fk": { 51 | "name": "sessions_user_id_users_id_fk", 52 | "tableFrom": "sessions", 53 | "tableTo": "users", 54 | "columnsFrom": [ 55 | "user_id" 56 | ], 57 | "columnsTo": [ 58 | "id" 59 | ], 60 | "onDelete": "no action", 61 | "onUpdate": "no action" 62 | } 63 | }, 64 | "compositePrimaryKeys": {}, 65 | "uniqueConstraints": {} 66 | }, 67 | "public.users": { 68 | "name": "users", 69 | "schema": "", 70 | "columns": { 71 | "id": { 72 | "name": "id", 73 | "type": "serial", 74 | "primaryKey": true, 75 | "notNull": true 76 | }, 77 | "email": { 78 | "name": "email", 79 | "type": "text", 80 | "primaryKey": false, 81 | "notNull": true 82 | }, 83 | "password": { 84 | "name": "password", 85 | "type": "text", 86 | "primaryKey": false, 87 | "notNull": true 88 | }, 89 | "full_name": { 90 | "name": "full_name", 91 | "type": "text", 92 | "primaryKey": false, 93 | "notNull": true 94 | }, 95 | "phone": { 96 | "name": "phone", 97 | "type": "text", 98 | "primaryKey": false, 99 | "notNull": false 100 | }, 101 | "country": { 102 | "name": "country", 103 | "type": "text", 104 | "primaryKey": false, 105 | "notNull": false 106 | }, 107 | "role": { 108 | "name": "role", 109 | "type": "text", 110 | "primaryKey": false, 111 | "notNull": true, 112 | "default": "'user'" 113 | }, 114 | "is_verified": { 115 | "name": "is_verified", 116 | "type": "boolean", 117 | "primaryKey": false, 118 | "notNull": true, 119 | "default": false 120 | }, 121 | "created_at": { 122 | "name": "created_at", 123 | "type": "timestamp with time zone", 124 | "primaryKey": false, 125 | "notNull": false, 126 | "default": "now()" 127 | } 128 | }, 129 | "indexes": {}, 130 | "foreignKeys": {}, 131 | "compositePrimaryKeys": {}, 132 | "uniqueConstraints": { 133 | "users_email_unique": { 134 | "name": "users_email_unique", 135 | "nullsNotDistinct": false, 136 | "columns": [ 137 | "email" 138 | ] 139 | } 140 | } 141 | }, 142 | "public.verification_codes": { 143 | "name": "verification_codes", 144 | "schema": "", 145 | "columns": { 146 | "id": { 147 | "name": "id", 148 | "type": "text", 149 | "primaryKey": true, 150 | "notNull": true 151 | }, 152 | "user_id": { 153 | "name": "user_id", 154 | "type": "integer", 155 | "primaryKey": false, 156 | "notNull": true 157 | }, 158 | "valid_until": { 159 | "name": "valid_until", 160 | "type": "timestamp with time zone", 161 | "primaryKey": false, 162 | "notNull": true 163 | }, 164 | "created_at": { 165 | "name": "created_at", 166 | "type": "timestamp with time zone", 167 | "primaryKey": false, 168 | "notNull": false, 169 | "default": "now()" 170 | } 171 | }, 172 | "indexes": {}, 173 | "foreignKeys": { 174 | "verification_codes_user_id_users_id_fk": { 175 | "name": "verification_codes_user_id_users_id_fk", 176 | "tableFrom": "verification_codes", 177 | "tableTo": "users", 178 | "columnsFrom": [ 179 | "user_id" 180 | ], 181 | "columnsTo": [ 182 | "id" 183 | ], 184 | "onDelete": "no action", 185 | "onUpdate": "no action" 186 | } 187 | }, 188 | "compositePrimaryKeys": {}, 189 | "uniqueConstraints": {} 190 | } 191 | }, 192 | "enums": {}, 193 | "schemas": {}, 194 | "sequences": {}, 195 | "_meta": { 196 | "columns": {}, 197 | "schemas": {}, 198 | "tables": {} 199 | } 200 | } -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1720865314674, 9 | "tag": "0000_aromatic_red_wolf", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "7", 15 | "when": 1720873504077, 16 | "tag": "0001_cuddly_red_skull", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "7", 22 | "when": 1720876728691, 23 | "tag": "0002_tiny_power_man", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "7", 29 | "when": 1720888364410, 30 | "tag": "0003_slimy_namora", 31 | "breakpoints": true 32 | }, 33 | { 34 | "idx": 4, 35 | "version": "7", 36 | "when": 1720888847275, 37 | "tag": "0004_next_jamie_braddock", 38 | "breakpoints": true 39 | }, 40 | { 41 | "idx": 5, 42 | "version": "7", 43 | "when": 1720888875577, 44 | "tag": "0005_married_veda", 45 | "breakpoints": true 46 | }, 47 | { 48 | "idx": 6, 49 | "version": "7", 50 | "when": 1721208986367, 51 | "tag": "0006_faithful_kree", 52 | "breakpoints": true 53 | }, 54 | { 55 | "idx": 7, 56 | "version": "7", 57 | "when": 1721213157912, 58 | "tag": "0007_reflective_tomas", 59 | "breakpoints": true 60 | }, 61 | { 62 | "idx": 8, 63 | "version": "7", 64 | "when": 1721213388939, 65 | "tag": "0008_outgoing_junta", 66 | "breakpoints": true 67 | } 68 | ] 69 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boilerplate", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev --host", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "lint": "prettier --check .", 12 | "format": "prettier --write .", 13 | "db:generate": "drizzle-kit generate", 14 | "db:migrate": "drizzle-kit migrate" 15 | }, 16 | "devDependencies": { 17 | "@iconify-json/solar": "^1.2.0", 18 | "@sveltejs/adapter-auto": "^3.2.5", 19 | "@sveltejs/kit": "^2.5.28", 20 | "@sveltejs/vite-plugin-svelte": "^4.0.0-next", 21 | "@tailwindcss/typography": "^0.5.15", 22 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 23 | "@types/node": "^22.6.1", 24 | "@types/ua-parser-js": "^0.7.39", 25 | "autoprefixer": "^10.4.20", 26 | "drizzle-kit": "^0.24.2", 27 | "postcss": "^8.4.47", 28 | "prettier": "^3.3.3", 29 | "prettier-plugin-svelte": "^3.2.6", 30 | "prettier-plugin-tailwindcss": "^0.6.6", 31 | "svelte": "^5.0.0-next.1", 32 | "svelte-check": "^4.0.2", 33 | "tailwindcss": "^3.4.13", 34 | "tslib": "^2.7.0", 35 | "typescript": "^5.6.2", 36 | "unplugin-icons": "^0.19.3", 37 | "vite": "^5.4.7" 38 | }, 39 | "type": "module", 40 | "dependencies": { 41 | "@lucia-auth/adapter-drizzle": "^1.1.0", 42 | "@node-rs/argon2": "^1.8.3", 43 | "arctic": "^1.9.2", 44 | "bits-ui": "^0.21.15", 45 | "bullmq": "^5.13.2", 46 | "clsx": "^2.1.1", 47 | "country-list-with-dial-code-and-flag": "^5.0.6", 48 | "date-fns": "^4.1.0", 49 | "drizzle-orm": "^0.33.0", 50 | "ioredis": "^5.4.1", 51 | "lucia": "^3.2.0", 52 | "mode-watcher": "^0.4.1", 53 | "postgres": "^3.4.4", 54 | "svelte-sonner": "^0.3.28", 55 | "tailwind-merge": "^2.5.2", 56 | "tailwind-variants": "^0.2.1", 57 | "totp-generator": "^1.0.0", 58 | "ua-parser-js": "^1.0.39", 59 | "vaul-svelte": "^0.3.2", 60 | "zod": "^3.23.8" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400..800&display=swap'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | @layer base { 8 | :root { 9 | --background: 0 0% 100%; 10 | --foreground: 0 0% 5%; 11 | 12 | --card: 0 0% 100%; 13 | --card-foreground: 0 0% 5%; 14 | 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 0 0% 5%; 17 | 18 | --primary: 86 78% 27%; 19 | --primary-foreground: 0 0% 98%; 20 | 21 | --secondary: 0 0% 96%; 22 | --secondary-foreground: 0 0% 5%; 23 | 24 | --muted: 0 0% 96%; 25 | --muted-foreground: 0 0% 45%; 26 | 27 | --accent: 0 0% 96%; 28 | --accent-foreground: 0 0% 5%; 29 | 30 | --destructive: 0 84% 60%; 31 | --destructive-foreground: 0 0% 98%; 32 | 33 | --border: 0 0% 92%; 34 | --input: 0 0% 85%; 35 | --ring: 0 0% 76%; 36 | 37 | --radius: 0.5rem; 38 | } 39 | 40 | .dark { 41 | --background: 80 7% 8%; 42 | --foreground: 60 9% 98%; 43 | 44 | --card: 80 6% 10%; 45 | --card-foreground: 60 9% 98%; 46 | 47 | --popover: 80 6% 10%; 48 | --popover-foreground: 60 9% 98%; 49 | 50 | --primary: 82 85% 67%; 51 | --primary-foreground: 120 9% 2%; 52 | 53 | --secondary: 83 8% 19%; 54 | --secondary-foreground: 60 9% 98%; 55 | 56 | --muted: 82 7% 29%; 57 | --muted-foreground: 81 8% 49%; 58 | 59 | --accent: 82 71% 19%; 60 | --accent-foreground: 60 9% 98%; 61 | 62 | --destructive: 0 93.5% 81.8%; 63 | --destructive-foreground: 0 72.2% 50.6%; 64 | 65 | --border: 84 8% 12%; 66 | --input: 82 9% 18%; 67 | --ring: 82 84% 40%; 68 | } 69 | } 70 | 71 | @layer base { 72 | * { 73 | @apply border-border; 74 | } 75 | body { 76 | @apply bg-background text-foreground; 77 | } 78 | h3 { 79 | @apply scroll-m-20 text-2xl font-semibold tracking-tight; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | import type { Session, User } from 'lucia'; 3 | 4 | // for information about these interfaces 5 | declare global { 6 | namespace App { 7 | // interface Error {} 8 | interface Locals { 9 | user: User | null; 10 | session: Session | null; 11 | team: { id: number; name: string } | null; 12 | body?: any | null | undefined; 13 | } 14 | // interface PageData {} 15 | // interface PageState {} 16 | // interface Platform {} 17 | } 18 | } 19 | 20 | export {}; 21 | -------------------------------------------------------------------------------- /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 { lucia } from '$lib/server/auth/adapter'; 2 | import { Role } from '$lib/server/auth/roles'; 3 | import { teamRepository } from '$lib/server/teams/repository'; 4 | import { redirect, type Handle } from '@sveltejs/kit'; 5 | import { sequence } from '@sveltejs/kit/hooks'; 6 | 7 | const authHandler: Handle = async ({ event, resolve }) => { 8 | const sessionId = event.cookies.get(lucia.sessionCookieName); 9 | 10 | if (!sessionId && !event.url.pathname.startsWith('/auth')) { 11 | return redirect(307, '/auth/login'); 12 | } 13 | 14 | if (!sessionId && event.url.pathname === '/auth/verify-email') { 15 | return redirect(307, '/auth/login'); 16 | } 17 | 18 | if (sessionId) { 19 | const { session, user } = await lucia.validateSession(sessionId); 20 | 21 | if (session && session.fresh) { 22 | const sessionCookie = lucia.createSessionCookie(session.id); 23 | event.cookies.set(sessionCookie.name, sessionCookie.value, { 24 | path: '.', 25 | ...sessionCookie.attributes 26 | }); 27 | } 28 | 29 | if (!session) { 30 | const sessionCookie = lucia.createBlankSessionCookie(); 31 | event.cookies.set(sessionCookie.name, sessionCookie.value, { 32 | path: '.', 33 | ...sessionCookie.attributes 34 | }); 35 | } 36 | 37 | if (user) { 38 | event.locals.user = user; 39 | event.locals.session = session; 40 | event.locals.team = (await teamRepository.findTeamById(user.activeTeamId)) ?? null; 41 | } 42 | } 43 | 44 | if (sessionId && event.url.pathname !== '/auth/verify-email' && !event.locals.user?.isVerified) { 45 | return redirect(307, '/auth/verify-email'); 46 | } 47 | 48 | if (event.url.pathname.startsWith('/admin') && event.locals.user?.role !== Role.Admin) { 49 | return redirect(307, '/home'); 50 | } 51 | 52 | return resolve(event); 53 | }; 54 | 55 | const bodyParseHandler: Handle = async ({ event, resolve }) => { 56 | if (event.request.body) { 57 | event.locals.body = Object.fromEntries(await event.request.formData()); 58 | } 59 | 60 | return resolve(event); 61 | }; 62 | 63 | export const handle: Handle = sequence(authHandler, bodyParseHandler); 64 | -------------------------------------------------------------------------------- /src/lib/components/partials/avatar-dropdown.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 16 | 17 | 18 | My Account 19 | 20 | Settings 21 | Support 22 | 23 | 24 |
25 | 26 |
27 |
28 |
29 |
30 | -------------------------------------------------------------------------------- /src/lib/components/partials/breadcrumb.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 26 | -------------------------------------------------------------------------------- /src/lib/components/partials/crud-table.svelte: -------------------------------------------------------------------------------- 1 | 44 | 45 |
46 | {#await body} 47 | 48 | {#if headers.length !== 0} 49 | 50 | 51 | {#each headers as header, index} 52 | {#if index === 0 || index === headers.length - 1} 53 | {header} 54 | {:else} 55 | 56 | {/if} 57 | {/each} 58 | 59 | 60 | {/if} 61 | 62 | 63 | {#each headers as _} 64 | 65 | 66 | 67 | {/each} 68 | 69 | 70 | 71 | {:then { data: items, count }} 72 | 73 | {#if headers.length !== 0} 74 | 75 | 76 | {#each headers as header, index} 77 | {#if index === 0 || index === headers.length - 1} 78 | {header} 79 | {:else} 80 | 81 | {/if} 82 | {/each} 83 | 84 | 85 | {/if} 86 | 87 | {#each items as item} 88 | {#if selected.find((i: any) => i.id === item.id)} 89 | handleSelected(item)}> 90 | {@render row(item)} 91 | 92 | {:else} 93 | handleSelected(item)}> 94 | {@render row(item)} 95 | 96 | {/if} 97 | {:else} 98 | 99 | {placeholder} 100 | 101 | {/each} 102 | 103 | 104 | 105 | {#if pagination} 106 | 113 | 114 | 115 | gotoToPage((currentPage ?? 1) - 1)} /> 116 | 117 | {#each pages as page (page.key)} 118 | {#if page.type === 'ellipsis'} 119 | 120 | 121 | 122 | {:else} 123 | 124 | gotoToPage(page.value)} 128 | > 129 | {page.value} 130 | 131 | 132 | {/if} 133 | {/each} 134 | 135 | gotoToPage((currentPage ?? 1) + 1)} /> 136 | 137 | 138 | 139 | {/if} 140 | {:catch e} 141 | 142 | 143 | {e.message} 144 | 145 | 146 | {/await} 147 |
148 | -------------------------------------------------------------------------------- /src/lib/components/partials/mobile-sidebar.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | {#snippet navItem(name: string, href: string, icon: any)} 15 | 20 | 21 | {name} 22 | 23 | {/snippet} 24 | 25 | 26 | 27 | 31 | 32 | 33 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/lib/components/partials/responsive-drawer.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | {#if isDesktop} 20 | 21 | 22 | 23 | 24 | {title} 25 | 26 | 27 | {description} 28 | 29 | 30 |
31 | {@render children()} 32 |
33 |
34 |
35 | {:else} 36 | 37 | 38 | 39 | 40 | {title} 41 | 42 | 43 | {description} 44 | 45 | 46 |
47 | {@render children()} 48 |
49 | 50 | 51 | 52 | 53 | 54 |
55 |
56 | {/if} 57 | -------------------------------------------------------------------------------- /src/lib/components/partials/sidebar.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | {#snippet navItem(name: string, href: string, icon: any)} 12 | 13 | 14 | 21 | 22 | {name} 23 | 24 | 25 | {name} 26 | 27 | {/snippet} 28 | 29 | 47 | -------------------------------------------------------------------------------- /src/lib/components/partials/team-select.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | {#await $page.data.teams} 15 | Loading... 16 | {:then { data: teams }} 17 | {#each teams as team} 18 | 25 | 29 | document 30 | .getElementById(`change-team-form-${team.id}`) 31 | ?.dispatchEvent(new SubmitEvent('submit'))} 32 | > 33 | {team.name} 38 | {team.name} 39 | 40 | {/each} 41 | {:catch e} 42 | {e.message} 43 | {/await} 44 | 45 | 46 |
47 | -------------------------------------------------------------------------------- /src/lib/components/partials/user-agent.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | {#if browser.name === 'Safari'} 13 | Safari 14 | {:else if browser.name === 'Chrome'} 15 | Chrome 16 | {:else if browser.name === 'Firefox'} 17 | Firefox 18 | {:else if browser.name === 'Edge'} 19 | Edge 20 | {:else} 21 | Unknown 22 | {/if} 23 | 24 | 25 | {browser.name} 26 | ({browser.version}) 27 | 28 |
29 | -------------------------------------------------------------------------------- /src/lib/components/ui/avatar/avatar-fallback.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/lib/components/ui/avatar/avatar-image.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /src/lib/components/ui/avatar/avatar.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/lib/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/components/ui/badge/badge.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/lib/components/ui/badge/index.ts: -------------------------------------------------------------------------------- 1 | import { type VariantProps, tv } from 'tailwind-variants'; 2 | 3 | export { default as Badge } from './badge.svelte'; 4 | 5 | export const badgeVariants = tv({ 6 | base: '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-ring focus:ring-offset-2', 7 | variants: { 8 | variant: { 9 | default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', 10 | secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 11 | destructive: 12 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', 13 | outline: 'text-foreground', 14 | warning: 15 | 'border-transparent bg-orange-400/15 text-orange-700 dark:bg-orange-300/15 dark:text-orange-300' 16 | } 17 | }, 18 | defaultVariants: { 19 | variant: 'default' 20 | } 21 | }); 22 | 23 | export type Variant = VariantProps['variant']; 24 | -------------------------------------------------------------------------------- /src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 25 | -------------------------------------------------------------------------------- /src/lib/components/ui/breadcrumb/breadcrumb-item.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
  • 15 | 16 |
  • 17 | -------------------------------------------------------------------------------- /src/lib/components/ui/breadcrumb/breadcrumb-link.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | {#if asChild} 26 | 27 | {:else} 28 | 29 | 30 | 31 | {/if} 32 | -------------------------------------------------------------------------------- /src/lib/components/ui/breadcrumb/breadcrumb-list.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
      22 | 23 |
    24 | -------------------------------------------------------------------------------- /src/lib/components/ui/breadcrumb/breadcrumb-page.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 26 | -------------------------------------------------------------------------------- /src/lib/components/ui/breadcrumb/breadcrumb.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 16 | -------------------------------------------------------------------------------- /src/lib/components/ui/breadcrumb/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./breadcrumb.svelte"; 2 | import Ellipsis from "./breadcrumb-ellipsis.svelte"; 3 | import Item from "./breadcrumb-item.svelte"; 4 | import Separator from "./breadcrumb-separator.svelte"; 5 | import Link from "./breadcrumb-link.svelte"; 6 | import List from "./breadcrumb-list.svelte"; 7 | import Page from "./breadcrumb-page.svelte"; 8 | 9 | export { 10 | Root, 11 | Ellipsis, 12 | Item, 13 | Separator, 14 | Link, 15 | List, 16 | Page, 17 | // 18 | Root as Breadcrumb, 19 | Ellipsis as BreadcrumbEllipsis, 20 | Item as BreadcrumbItem, 21 | Separator as BreadcrumbSeparator, 22 | Link as BreadcrumbLink, 23 | List as BreadcrumbList, 24 | Page as BreadcrumbPage, 25 | }; 26 | -------------------------------------------------------------------------------- /src/lib/components/ui/button/button.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/lib/components/ui/button/index.ts: -------------------------------------------------------------------------------- 1 | import Root from './button.svelte'; 2 | import type { Button as ButtonPrimitive } from 'bits-ui'; 3 | import { type VariantProps, tv } from 'tailwind-variants'; 4 | 5 | const buttonVariants = tv({ 6 | base: 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring 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/15 text-destructive hover:bg-destructive/30', 11 | outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 12 | secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 13 | ghost: 'hover:bg-accent hover:text-accent-foreground', 14 | link: 'text-primary underline-offset-4 hover:underline' 15 | }, 16 | size: { 17 | default: 'h-10 px-4 py-2', 18 | sm: 'h-9 rounded-md px-3', 19 | lg: 'h-11 rounded-md px-8', 20 | icon: 'h-10 w-10' 21 | } 22 | }, 23 | defaultVariants: { 24 | variant: 'default', 25 | size: 'default' 26 | } 27 | }); 28 | 29 | type Variant = VariantProps['variant']; 30 | type Size = VariantProps['size']; 31 | 32 | type Props = ButtonPrimitive.Props & { 33 | variant?: Variant; 34 | size?: Size; 35 | }; 36 | 37 | type Events = ButtonPrimitive.Events; 38 | 39 | export { 40 | Root, 41 | type Props, 42 | type Events, 43 | // 44 | Root as Button, 45 | type Props as ButtonProps, 46 | type Events as ButtonEvents, 47 | buttonVariants 48 | }; 49 | -------------------------------------------------------------------------------- /src/lib/components/ui/card/card-content.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    12 | 13 |
    14 | -------------------------------------------------------------------------------- /src/lib/components/ui/card/card-description.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |

    12 | 13 |

    14 | -------------------------------------------------------------------------------- /src/lib/components/ui/card/card-footer.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    12 | 13 |
    14 | -------------------------------------------------------------------------------- /src/lib/components/ui/card/card-header.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    12 | 13 |
    14 | -------------------------------------------------------------------------------- /src/lib/components/ui/card/card-title.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/lib/components/ui/card/card.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    15 | 16 |
    17 | -------------------------------------------------------------------------------- /src/lib/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/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/components/ui/checkbox/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./checkbox.svelte"; 2 | export { 3 | Root, 4 | // 5 | Root as Checkbox, 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-content.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 28 | 29 | 32 | 33 | Close 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-description.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-footer.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    15 | 16 |
    17 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-header.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    12 | 13 |
    14 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-overlay.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 22 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-portal.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-title.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/lib/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/components/ui/drawer/drawer-content.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 21 |
    22 | 23 |
    24 |
    25 | -------------------------------------------------------------------------------- /src/lib/components/ui/drawer/drawer-description.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/lib/components/ui/drawer/drawer-footer.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
    15 | 16 |
    17 | -------------------------------------------------------------------------------- /src/lib/components/ui/drawer/drawer-header.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    18 | 19 |
    20 | -------------------------------------------------------------------------------- /src/lib/components/ui/drawer/drawer-nested.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/components/ui/drawer/drawer-overlay.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/lib/components/ui/drawer/drawer-title.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/lib/components/ui/drawer/drawer.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/components/ui/drawer/index.ts: -------------------------------------------------------------------------------- 1 | import { Drawer as DrawerPrimitive } from "vaul-svelte"; 2 | 3 | import Root from "./drawer.svelte"; 4 | import Content from "./drawer-content.svelte"; 5 | import Description from "./drawer-description.svelte"; 6 | import Overlay from "./drawer-overlay.svelte"; 7 | import Footer from "./drawer-footer.svelte"; 8 | import Header from "./drawer-header.svelte"; 9 | import Title from "./drawer-title.svelte"; 10 | import NestedRoot from "./drawer-nested.svelte"; 11 | 12 | const Trigger = DrawerPrimitive.Trigger; 13 | const Portal = DrawerPrimitive.Portal; 14 | const Close = DrawerPrimitive.Close; 15 | 16 | export { 17 | Root, 18 | NestedRoot, 19 | Content, 20 | Description, 21 | Overlay, 22 | Footer, 23 | Header, 24 | Title, 25 | Trigger, 26 | Portal, 27 | Close, 28 | 29 | // 30 | Root as Drawer, 31 | NestedRoot as DrawerNestedRoot, 32 | Content as DrawerContent, 33 | Description as DrawerDescription, 34 | Overlay as DrawerOverlay, 35 | Footer as DrawerFooter, 36 | Header as DrawerHeader, 37 | Title as DrawerTitle, 38 | Trigger as DrawerTrigger, 39 | Portal as DrawerPortal, 40 | Close as DrawerClose, 41 | }; 42 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 15 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/lib/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/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/components/ui/input/input.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 45 | {#if error && error.toString() !== 'true' && error.toString() !== 'false'} 46 | {error} 47 | {/if} 48 | -------------------------------------------------------------------------------- /src/lib/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/components/ui/label/label.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/lib/components/ui/pagination/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./pagination.svelte"; 2 | import Content from "./pagination-content.svelte"; 3 | import Item from "./pagination-item.svelte"; 4 | import Link from "./pagination-link.svelte"; 5 | import PrevButton from "./pagination-prev-button.svelte"; 6 | import NextButton from "./pagination-next-button.svelte"; 7 | import Ellipsis from "./pagination-ellipsis.svelte"; 8 | 9 | export { 10 | Root, 11 | Content, 12 | Item, 13 | Link, 14 | PrevButton, 15 | NextButton, 16 | Ellipsis, 17 | // 18 | Root as Pagination, 19 | Content as PaginationContent, 20 | Item as PaginationItem, 21 | Link as PaginationLink, 22 | PrevButton as PaginationPrevButton, 23 | NextButton as PaginationNextButton, 24 | Ellipsis as PaginationEllipsis, 25 | }; 26 | -------------------------------------------------------------------------------- /src/lib/components/ui/pagination/pagination-content.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
      12 | 13 |
    14 | -------------------------------------------------------------------------------- /src/lib/components/ui/pagination/pagination-ellipsis.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | -------------------------------------------------------------------------------- /src/lib/components/ui/pagination/pagination-item.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
  • 12 | 13 |
  • 14 | -------------------------------------------------------------------------------- /src/lib/components/ui/pagination/pagination-link.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 33 | {page.value} 34 | 35 | -------------------------------------------------------------------------------- /src/lib/components/ui/pagination/pagination-next-button.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 27 | 28 | -------------------------------------------------------------------------------- /src/lib/components/ui/pagination/pagination-prev-button.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 27 | 28 | -------------------------------------------------------------------------------- /src/lib/components/ui/pagination/pagination.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 30 | 33 | 34 | -------------------------------------------------------------------------------- /src/lib/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/components/ui/popover/popover-content.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/lib/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/components/ui/select/select-content.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | 36 |
    37 | 38 |
    39 |
    40 | -------------------------------------------------------------------------------- /src/lib/components/ui/select/select-item.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 33 | 34 | {label || value} 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/lib/components/ui/select/select-label.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/lib/components/ui/select/select-separator.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/lib/components/ui/select/select-trigger.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | span]:line-clamp-1 data-[placeholder]:[&>span]:text-muted-foreground', 16 | className 17 | )} 18 | {...$$restProps} 19 | let:builder 20 | on:click 21 | on:keydown 22 | > 23 | 24 |
    25 | 26 |
    27 |
    28 | -------------------------------------------------------------------------------- /src/lib/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: "fixed z-50 gap-4 bg-background 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/components/ui/sheet/sheet-content.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | 30 | 31 | 39 | 40 | 43 | 44 | Close 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/sheet-description.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/sheet-footer.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    15 | 16 |
    17 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/sheet-header.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    12 | 13 |
    14 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/sheet-overlay.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 22 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/sheet-portal.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/sheet-title.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/lib/components/ui/skeleton/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./skeleton.svelte"; 2 | 3 | export { 4 | Root, 5 | // 6 | Root as Skeleton, 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/components/ui/skeleton/skeleton.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    12 | -------------------------------------------------------------------------------- /src/lib/components/ui/sonner/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Toaster } from "./sonner.svelte"; 2 | -------------------------------------------------------------------------------- /src/lib/components/ui/sonner/sonner.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 21 | -------------------------------------------------------------------------------- /src/lib/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/components/ui/table/table-body.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/components/ui/table/table-caption.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/components/ui/table/table-cell.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/lib/components/ui/table/table-footer.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/components/ui/table/table-head.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/lib/components/ui/table/table-header.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/lib/components/ui/table/table-row.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/lib/components/ui/table/table.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
    13 | 14 | 15 |
    16 |
    17 | -------------------------------------------------------------------------------- /src/lib/components/ui/tabs/index.ts: -------------------------------------------------------------------------------- 1 | import { Tabs as TabsPrimitive } from "bits-ui"; 2 | import Content from "./tabs-content.svelte"; 3 | import List from "./tabs-list.svelte"; 4 | import Trigger from "./tabs-trigger.svelte"; 5 | 6 | const Root = TabsPrimitive.Root; 7 | 8 | export { 9 | Root, 10 | Content, 11 | List, 12 | Trigger, 13 | // 14 | Root as Tabs, 15 | Content as TabsContent, 16 | List as TabsList, 17 | Trigger as TabsTrigger, 18 | }; 19 | -------------------------------------------------------------------------------- /src/lib/components/ui/tabs/tabs-content.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/lib/components/ui/tabs/tabs-list.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/lib/components/ui/tabs/tabs-trigger.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/lib/components/ui/tooltip/index.ts: -------------------------------------------------------------------------------- 1 | import { Tooltip as TooltipPrimitive } from "bits-ui"; 2 | import Content from "./tooltip-content.svelte"; 3 | 4 | const Root = TooltipPrimitive.Root; 5 | const Trigger = TooltipPrimitive.Trigger; 6 | 7 | export { 8 | Root, 9 | Trigger, 10 | Content, 11 | // 12 | Root as Tooltip, 13 | Content as TooltipContent, 14 | Trigger as TooltipTrigger, 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/components/ui/tooltip/tooltip-content.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/lib/server/auth/adapter.ts: -------------------------------------------------------------------------------- 1 | import { sessionTable, userTable } from '$lib/server/database/schema'; 2 | import type { Role } from './roles'; 3 | import { DrizzlePostgreSQLAdapter } from '@lucia-auth/adapter-drizzle'; 4 | import { Lucia } from 'lucia'; 5 | import { db } from '../database/adapter'; 6 | 7 | const adapter = new DrizzlePostgreSQLAdapter(db, sessionTable, userTable); 8 | 9 | export const lucia = new Lucia(adapter, { 10 | getUserAttributes: (attributes) => { 11 | return { 12 | id: attributes.id, 13 | fullName: attributes.fullName, 14 | email: attributes.email, 15 | isVerified: attributes.isVerified, 16 | role: attributes.role, 17 | activeTeamId: attributes.activeTeamId 18 | }; 19 | }, 20 | getSessionAttributes: (attributes) => { 21 | return { 22 | ip: attributes.ip, 23 | userAgent: attributes.userAgent, 24 | country: attributes.country 25 | }; 26 | }, 27 | sessionCookie: { 28 | attributes: { 29 | secure: process.env.NODE_ENV === 'production' 30 | } 31 | } 32 | }); 33 | 34 | declare module 'lucia' { 35 | interface Register { 36 | Lucia: typeof lucia; 37 | UserId: number; 38 | DatabaseUserAttributes: { 39 | id: number; 40 | fullName: string; 41 | email: string; 42 | isVerified: boolean; 43 | role: Role; 44 | activeTeamId: number; 45 | }; 46 | DatabaseSessionAttributes: { 47 | ip: string; 48 | userAgent: string; 49 | country: string; 50 | }; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/lib/server/auth/dtos.ts: -------------------------------------------------------------------------------- 1 | import { Role } from './roles'; 2 | import { z } from 'zod'; 3 | 4 | export const loginDto = z.object({ 5 | email: z.string().email(), 6 | password: z.string().min(6) 7 | }); 8 | 9 | export const registerDto = z.object({ 10 | email: z.string().email(), 11 | password: z.string().min(6), 12 | fullName: z.string().min(2) 13 | }); 14 | 15 | export const verifyEmailDto = z.object({ 16 | code: z.string().length(16) 17 | }); 18 | 19 | export const deleteSessionDto = z.object({ 20 | sessionId: z.string() 21 | }); 22 | 23 | export const updateCredenitlasDto = z.object({ 24 | email: z.string().email(), 25 | fullName: z.string().min(2), 26 | password: z.string().optional().nullable(), 27 | role: z.enum([Role.Admin, Role.User]) 28 | }); 29 | -------------------------------------------------------------------------------- /src/lib/server/auth/repository.ts: -------------------------------------------------------------------------------- 1 | import { type Role } from './roles'; 2 | import { hash, verify } from '@node-rs/argon2'; 3 | import { subMinutes } from 'date-fns'; 4 | import { and, count, desc, eq, gte } from 'drizzle-orm'; 5 | import { generateIdFromEntropySize } from 'lucia'; 6 | import { db } from '../database/adapter'; 7 | import { 8 | userTable, 9 | teamTable, 10 | verificationCodesTable, 11 | sessionTable, 12 | teamMemberTable, 13 | oauthConnectionsTable 14 | } from '../database/schema'; 15 | import { TeamRole } from '../teams/roles'; 16 | 17 | class AuthRepository { 18 | async findUsers(page: number, limit: number = 20) { 19 | const users = await db.query.userTable.findMany({ 20 | offset: limit * (page - 1), 21 | orderBy: [desc(userTable.id)] 22 | }); 23 | 24 | const [{ count: usersCount }] = await db 25 | .select({ count: count() }) 26 | .from(userTable) 27 | .offset(limit * (page - 1)); 28 | 29 | return { data: users, count: usersCount }; 30 | } 31 | 32 | async findUserByEmail(email: string) { 33 | const user = await db.query.userTable.findFirst({ where: eq(userTable.email, email) }); 34 | 35 | if (!user) { 36 | return; 37 | } 38 | 39 | return user; 40 | } 41 | 42 | async findUserById(id: number) { 43 | const user = await db.query.userTable.findFirst({ where: eq(userTable.id, id) }); 44 | 45 | if (!user) { 46 | return; 47 | } 48 | 49 | return user; 50 | } 51 | 52 | async isUniqueEmail(email: string) { 53 | const user = await db.query.userTable.findFirst({ 54 | where: eq(userTable.email, email), 55 | columns: { 56 | email: true 57 | } 58 | }); 59 | 60 | return !user; 61 | } 62 | 63 | async findUserByEmailAndPassword(email: string, password: string) { 64 | const user = await db.query.userTable.findFirst({ where: eq(userTable.email, email) }); 65 | 66 | if (!user) { 67 | return; 68 | } 69 | 70 | const isValid = await verify(user.password, password); 71 | 72 | if (isValid) { 73 | return user; 74 | } 75 | } 76 | 77 | async findSessionsByUserId(userId: number) { 78 | const sessions = await db.query.sessionTable.findMany({ 79 | where: eq(sessionTable.userId, userId) 80 | }); 81 | 82 | const [{ count: sessionsCount }] = await db 83 | .select({ count: count() }) 84 | .from(sessionTable) 85 | .where(eq(sessionTable.userId, userId)); 86 | 87 | return { data: sessions, count: sessionsCount }; 88 | } 89 | 90 | async createUser(email: string, password: string, fullName: string) { 91 | const hashedPassword = await hash(password); 92 | 93 | return await db.transaction(async (trx) => { 94 | const [{ id: teamId }] = await trx 95 | .insert(teamTable) 96 | .values({ name: `${fullName}'s Team` }) 97 | .returning(); 98 | 99 | const [user] = await trx 100 | .insert(userTable) 101 | .values({ 102 | email, 103 | password: hashedPassword, 104 | fullName, 105 | activeTeamId: teamId 106 | }) 107 | .returning(); 108 | 109 | await trx.insert(teamMemberTable).values({ 110 | teamId: teamId, 111 | userId: user.id, 112 | role: TeamRole.Admin 113 | }); 114 | 115 | return user; 116 | }); 117 | } 118 | 119 | async createVerificationCode(userId: number, validUntil: Date) { 120 | const existingCode = await db.query.verificationCodesTable.findFirst({ 121 | where: and( 122 | eq(verificationCodesTable.userId, userId), 123 | gte(verificationCodesTable.validUntil, subMinutes(new Date(), 30)) 124 | ) 125 | }); 126 | 127 | if (existingCode) { 128 | return existingCode; 129 | } 130 | 131 | const code = await db 132 | .insert(verificationCodesTable) 133 | .values({ 134 | id: generateIdFromEntropySize(10), 135 | userId, 136 | validUntil 137 | }) 138 | .returning(); 139 | 140 | return code[0]; 141 | } 142 | 143 | async updateUser( 144 | id: number, 145 | email: string, 146 | fullName: string, 147 | role: Role, 148 | password?: string | null 149 | ) { 150 | const hashedPassword = password ? await hash(password) : undefined; 151 | 152 | if (password) { 153 | await db 154 | .update(userTable) 155 | .set({ email, fullName, password: hashedPassword }) 156 | .where(eq(userTable.id, id)); 157 | } 158 | 159 | const user = await db 160 | .update(userTable) 161 | .set({ email, fullName, role }) 162 | .where(eq(userTable.id, id)) 163 | .returning(); 164 | 165 | return user[0]; 166 | } 167 | 168 | async verifyEmail(id: number, code: string) { 169 | const verificationCode = await db.query.verificationCodesTable.findFirst({ 170 | where: and( 171 | eq(verificationCodesTable.id, code), 172 | eq(verificationCodesTable.userId, id), 173 | gte(verificationCodesTable.validUntil, new Date()) 174 | ) 175 | }); 176 | 177 | if (!verificationCode) { 178 | return false; 179 | } 180 | 181 | await db.transaction(async (trx) => { 182 | await trx.delete(verificationCodesTable).where(eq(verificationCodesTable.id, code)); 183 | await trx.update(userTable).set({ isVerified: true }).where(eq(userTable.id, id)); 184 | }); 185 | 186 | return true; 187 | } 188 | 189 | async verifyEmailWithoutCode(id: number) { 190 | await db.update(userTable).set({ isVerified: true }).where(eq(userTable.id, id)); 191 | } 192 | 193 | async deleteUser(id: number) { 194 | await db.transaction(async (trx) => { 195 | await trx.delete(verificationCodesTable).where(eq(verificationCodesTable.userId, id)); 196 | await trx.delete(sessionTable).where(eq(sessionTable.userId, id)); 197 | await trx.delete(userTable).where(eq(userTable.id, id)); 198 | }); 199 | } 200 | 201 | async createOAuthConnection( 202 | provider: string, 203 | profile: { avatarUrl: string; email: string; providerUserId: string; name: string } 204 | ) { 205 | const user = await db.query.userTable.findFirst({ 206 | columns: { id: true }, 207 | where: eq(userTable.email, profile.email) 208 | }); 209 | 210 | const existingConnection = await db.query.oauthConnectionsTable.findFirst({ 211 | where: and( 212 | eq(oauthConnectionsTable.provider, provider), 213 | eq(oauthConnectionsTable.providerUserId, profile.providerUserId) 214 | ) 215 | }); 216 | 217 | if (user) { 218 | if (existingConnection) { 219 | return existingConnection; 220 | } 221 | 222 | const [connection] = await db 223 | .insert(oauthConnectionsTable) 224 | .values({ 225 | provider, 226 | providerUserId: profile.providerUserId, 227 | userId: user.id, 228 | email: profile.email 229 | }) 230 | .returning(); 231 | 232 | return connection; 233 | } 234 | 235 | return db.transaction(async (trx) => { 236 | const [{ id: teamId }] = await trx 237 | .insert(teamTable) 238 | .values({ name: `${profile.name}'s Team` }) 239 | .returning(); 240 | 241 | const [user] = await trx 242 | .insert(userTable) 243 | .values({ 244 | email: profile.email, 245 | password: generateIdFromEntropySize(10), 246 | fullName: profile.name, 247 | activeTeamId: teamId, 248 | isVerified: true 249 | }) 250 | .returning(); 251 | 252 | await trx.insert(teamMemberTable).values({ 253 | teamId: teamId, 254 | userId: user.id, 255 | role: TeamRole.Admin 256 | }); 257 | 258 | const [connection] = await trx 259 | .insert(oauthConnectionsTable) 260 | .values({ 261 | provider, 262 | providerUserId: profile.providerUserId, 263 | email: profile.email, 264 | userId: user.id 265 | }) 266 | .returning(); 267 | 268 | return connection; 269 | }); 270 | } 271 | } 272 | 273 | export const authRepository = new AuthRepository(); 274 | -------------------------------------------------------------------------------- /src/lib/server/auth/roles.ts: -------------------------------------------------------------------------------- 1 | export enum Role { 2 | Admin = 'admin', 3 | User = 'user' 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/server/auth/services/oauth-service.ts: -------------------------------------------------------------------------------- 1 | import { SECRET_GOOGLE_CLIENT_KEY } from '$env/static/private'; 2 | import { PUBLIC_GOOGLE_CLIENT_ID, PUBLIC_OAUTH_SUPPORTED_PROVIDERS } from '$env/static/public'; 3 | import { capitalize } from '$lib/utils'; 4 | import { generateCodeVerifier, generateState, Google } from 'arctic'; 5 | 6 | class OAuthService { 7 | isSupportedProvider(provider: string) { 8 | return PUBLIC_OAUTH_SUPPORTED_PROVIDERS.split(',').includes(provider); 9 | } 10 | 11 | async getRedirectUrl(provider: string) { 12 | const state = generateState(); 13 | const codeVerifier = generateCodeVerifier(); 14 | let redirectUrl: URL | undefined; 15 | 16 | if (provider === 'google') { 17 | redirectUrl = await this.getGoogleProvider().createAuthorizationURL(state, codeVerifier, { 18 | scopes: ['https://www.googleapis.com/auth/userinfo.email'] 19 | }); 20 | } 21 | 22 | return { state, codeVerifier, redirectUrl }; 23 | } 24 | 25 | async validateAuthorizationCode(provider: string, code: string, codeVerifier: string) { 26 | if (provider === 'google') { 27 | return await this.getGoogleProvider().validateAuthorizationCode(code, codeVerifier); 28 | } 29 | } 30 | 31 | async getProfile(provider: string, bearerToken: string, fetch: typeof window.fetch) { 32 | if (provider === 'google') { 33 | return await this.getGoogleProfile(bearerToken, fetch); 34 | } 35 | } 36 | 37 | private getGoogleProvider() { 38 | return new Google( 39 | PUBLIC_GOOGLE_CLIENT_ID, 40 | SECRET_GOOGLE_CLIENT_KEY, 41 | `${import.meta.env.DEV ? 'http://localhost:5173' : 'https://example.com'}/auth/oauth/callback` 42 | ); 43 | } 44 | 45 | private async getGoogleProfile(bearerToken: string, fetch: typeof window.fetch) { 46 | try { 47 | const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { 48 | headers: { Authorization: `Bearer ${bearerToken}` } 49 | }); 50 | 51 | if (!response.ok) { 52 | return; 53 | } 54 | 55 | const profile = await response.json(); 56 | 57 | return { 58 | avatarUrl: profile.picture, 59 | name: capitalize(profile.email.split('@')[0]), 60 | email: profile.email, 61 | providerUserId: profile.id 62 | }; 63 | } catch (e) { 64 | console.error(e); 65 | } 66 | } 67 | } 68 | 69 | export const oauthService = new OAuthService(); 70 | -------------------------------------------------------------------------------- /src/lib/server/cache/adapter.ts: -------------------------------------------------------------------------------- 1 | import { SECRET_REDIS_URL } from '$env/static/private'; 2 | import IORedis from 'ioredis'; 3 | 4 | export const redis = new IORedis(SECRET_REDIS_URL, { 5 | maxRetriesPerRequest: null, 6 | connectTimeout: 15_000 7 | }); 8 | -------------------------------------------------------------------------------- /src/lib/server/database/adapter.ts: -------------------------------------------------------------------------------- 1 | import { SECRET_DATABASE_URL } from '$env/static/private'; 2 | import { 3 | oauthConnectionsTable, 4 | sessionTable, 5 | teamMemberRelations, 6 | teamMemberTable, 7 | teamTable, 8 | userTable, 9 | verificationCodesTable 10 | } from './schema'; 11 | import { drizzle } from 'drizzle-orm/postgres-js'; 12 | import postgres from 'postgres'; 13 | 14 | const client = postgres(SECRET_DATABASE_URL); 15 | 16 | export const db = drizzle(client, { 17 | schema: { 18 | userTable, 19 | sessionTable, 20 | verificationCodesTable, 21 | teamTable, 22 | teamMemberTable, 23 | teamMemberRelations, 24 | oauthConnectionsTable 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /src/lib/server/database/schema.ts: -------------------------------------------------------------------------------- 1 | import { relations } from 'drizzle-orm'; 2 | import { 3 | boolean, 4 | integer, 5 | pgTable, 6 | primaryKey, 7 | serial, 8 | text, 9 | timestamp 10 | } from 'drizzle-orm/pg-core'; 11 | import { Role } from '../auth/roles'; 12 | import { TeamRole } from '../teams/roles'; 13 | 14 | export const userTable = pgTable('users', { 15 | id: serial('id').primaryKey(), 16 | email: text('email').unique().notNull(), 17 | password: text('password').notNull(), 18 | fullName: text('full_name').notNull(), 19 | phone: text('phone'), 20 | country: text('country'), 21 | role: text('role').notNull().default(Role.User), 22 | activeTeamId: integer('active_team_id') 23 | .references(() => teamTable.id) 24 | .notNull(), 25 | isVerified: boolean('is_verified').notNull().default(false), 26 | createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow() 27 | }); 28 | 29 | export const oauthConnectionsTable = pgTable( 30 | 'oauth_connections', 31 | { 32 | userId: integer('user_id') 33 | .notNull() 34 | .references(() => userTable.id, { onDelete: 'cascade' }), 35 | email: text('email').notNull(), 36 | providerUserId: text('provider_user_id').notNull(), 37 | provider: text('provider').notNull() 38 | }, 39 | (table) => ({ 40 | pk: primaryKey({ columns: [table.providerUserId, table.userId] }) 41 | }) 42 | ); 43 | 44 | export const teamTable = pgTable('teams', { 45 | id: serial('id').primaryKey(), 46 | name: text('name').notNull(), 47 | createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow() 48 | }); 49 | 50 | export const teamMemberTable = pgTable( 51 | 'team_members', 52 | { 53 | teamId: integer('team_id') 54 | .notNull() 55 | .references(() => teamTable.id, { onDelete: 'cascade' }), 56 | userId: integer('user_id') 57 | .notNull() 58 | .references(() => userTable.id, { onDelete: 'cascade' }), 59 | role: text('role').notNull().default(TeamRole.Member), 60 | createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow() 61 | }, 62 | (table) => ({ 63 | pk: primaryKey({ columns: [table.teamId, table.userId] }) 64 | }) 65 | ); 66 | 67 | export const sessionTable = pgTable('sessions', { 68 | id: text('id').primaryKey(), 69 | userId: integer('user_id') 70 | .notNull() 71 | .references(() => userTable.id), 72 | ip: text('ip').notNull(), 73 | country: text('country').notNull(), 74 | userAgent: text('user_agent').notNull(), 75 | expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull() 76 | }); 77 | 78 | export const verificationCodesTable = pgTable('verification_codes', { 79 | id: text('id').primaryKey(), 80 | userId: integer('user_id') 81 | .notNull() 82 | .references(() => userTable.id), 83 | validUntil: timestamp('valid_until', { withTimezone: true, mode: 'date' }).notNull(), 84 | createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow() 85 | }); 86 | 87 | export const teamMemberRelations = relations(teamMemberTable, ({ one }) => ({ 88 | user: one(userTable, { 89 | fields: [teamMemberTable.userId], 90 | references: [userTable.id] 91 | }), 92 | team: one(teamTable, { 93 | fields: [teamMemberTable.teamId], 94 | references: [teamTable.id] 95 | }) 96 | })); 97 | -------------------------------------------------------------------------------- /src/lib/server/email/queues.ts: -------------------------------------------------------------------------------- 1 | import { Queue } from 'bullmq'; 2 | 3 | export const sendEmailQueue = new Queue('send-email'); 4 | -------------------------------------------------------------------------------- /src/lib/server/email/service.ts: -------------------------------------------------------------------------------- 1 | class EmailService { 2 | async sendEmail(to: string, html: string) {} 3 | } 4 | 5 | export const emailService = new EmailService(); 6 | -------------------------------------------------------------------------------- /src/lib/server/email/workers.ts: -------------------------------------------------------------------------------- 1 | import { emailService } from './service'; 2 | import { Worker, type Job } from 'bullmq'; 3 | import { redis } from '../cache/adapter'; 4 | 5 | const sendEmailHandler = async (job: Job) => { 6 | const { to, html } = job.data; 7 | await emailService.sendEmail(to, html); 8 | }; 9 | 10 | export const sendEmailWorker = new Worker('send-email', sendEmailHandler, { 11 | concurrency: 1, 12 | connection: redis 13 | }); 14 | -------------------------------------------------------------------------------- /src/lib/server/teams/dtos.ts: -------------------------------------------------------------------------------- 1 | import { TeamRole } from './roles'; 2 | import { z } from 'zod'; 3 | 4 | export const updateTeamDto = z.object({ 5 | name: z.string().min(2) 6 | }); 7 | 8 | export const createTeamDto = z.object({ 9 | name: z.string().min(2) 10 | }); 11 | 12 | export const removeTeamMemberDto = z.object({ 13 | userId: z.coerce.number().min(1) 14 | }); 15 | 16 | export const addTeamMemberDto = z.object({ 17 | email: z.string().email(), 18 | role: z.enum([TeamRole.Admin, TeamRole.Member]) 19 | }); 20 | -------------------------------------------------------------------------------- /src/lib/server/teams/repository.ts: -------------------------------------------------------------------------------- 1 | import { TeamRole } from './roles'; 2 | import { and, asc, count, desc, eq } from 'drizzle-orm'; 3 | import { db } from '../database/adapter'; 4 | import { teamMemberTable, teamTable, userTable } from '../database/schema'; 5 | 6 | class TeamRepository { 7 | async findTeamsByUserId(userId: number) { 8 | const memberships = await db.query.teamMemberTable.findMany({ 9 | where: eq(teamMemberTable.userId, userId), 10 | with: { team: true } 11 | }); 12 | 13 | return { 14 | data: memberships.flatMap((membership) => membership.team), 15 | count: memberships.length 16 | }; 17 | } 18 | 19 | async findTeamById(teamId: number) { 20 | const team = await db.query.teamTable.findFirst({ where: eq(teamTable.id, teamId) }); 21 | 22 | if (!team) { 23 | return; 24 | } 25 | 26 | return team; 27 | } 28 | 29 | async findTeamMemberByUserId(teamId: number, userId: number) { 30 | return db.query.teamMemberTable.findFirst({ 31 | where: and(eq(teamMemberTable.userId, userId), eq(teamMemberTable.teamId, teamId)) 32 | }); 33 | } 34 | 35 | async isTeamMember(teamId: number, userId: number) { 36 | const teamMember = await db.query.teamMemberTable.findFirst({ 37 | where: and(eq(teamMemberTable.userId, userId), eq(teamMemberTable.teamId, teamId)), 38 | columns: { teamId: true } 39 | }); 40 | 41 | return !!teamMember; 42 | } 43 | 44 | async updateTeamMember(teamId: number, userId: number, role: TeamRole) { 45 | await db 46 | .update(teamMemberTable) 47 | .set({ role }) 48 | .where(and(eq(teamMemberTable.teamId, teamId), eq(teamMemberTable.userId, userId))); 49 | } 50 | 51 | async findTeamMembers(teamId: number, page: number, limit: number = 20) { 52 | const members = await db.query.teamMemberTable.findMany({ 53 | where: eq(teamMemberTable.teamId, teamId), 54 | offset: limit * (page - 1), 55 | orderBy: [desc(teamMemberTable.userId)], 56 | with: { user: true } 57 | }); 58 | 59 | return { 60 | data: members.map((member) => ({ 61 | ...member.user, 62 | joinedAt: member.createdAt, 63 | teamRole: member.role 64 | })), 65 | count: await db 66 | .select({ count: count() }) 67 | .from(teamMemberTable) 68 | .where(eq(teamMemberTable.teamId, teamId)) 69 | }; 70 | } 71 | 72 | async updateTeam(id: number, name: string) { 73 | await db.update(teamTable).set({ name }).where(eq(teamTable.id, id)); 74 | } 75 | 76 | async createTeam(name: string, userId: number) { 77 | return await db.transaction(async (trx) => { 78 | const [team] = await db.insert(teamTable).values({ name: name }).returning(); 79 | await trx.insert(teamMemberTable).values({ teamId: team.id, userId, role: TeamRole.Admin }); 80 | return team; 81 | }); 82 | } 83 | 84 | async addTeamMember(teamId: number, userId: number, role: TeamRole) { 85 | const membership = await db.query.teamMemberTable.findFirst({ 86 | where: and(eq(teamMemberTable.teamId, teamId), eq(teamMemberTable.userId, userId)) 87 | }); 88 | 89 | if (membership) { 90 | return false; 91 | } 92 | 93 | await db.insert(teamMemberTable).values({ teamId, userId, role }); 94 | return true; 95 | } 96 | 97 | async removeTeamMember(teamId: number, userId: number) { 98 | return db.transaction(async (trx) => { 99 | await trx 100 | .delete(teamMemberTable) 101 | .where(and(eq(teamMemberTable.teamId, teamId), eq(teamMemberTable.userId, userId))); 102 | 103 | const [{ count: membersCount }] = await db 104 | .select({ count: count() }) 105 | .from(teamMemberTable) 106 | .where(eq(teamMemberTable.teamId, teamId)); 107 | 108 | const [{ count: adminCount }] = await db 109 | .select({ count: count() }) 110 | .from(teamMemberTable) 111 | .where(and(eq(teamMemberTable.teamId, teamId), eq(teamMemberTable.role, TeamRole.Admin))); 112 | 113 | if (membersCount < 0) { 114 | trx.rollback(); 115 | return false; 116 | } 117 | 118 | if (adminCount === 0) { 119 | const oldestMembership = await trx.query.teamMemberTable.findFirst({ 120 | orderBy: [asc(teamMemberTable.createdAt)] 121 | }); 122 | 123 | await trx 124 | .update(teamMemberTable) 125 | .set({ role: TeamRole.Admin }) 126 | .where(eq(teamMemberTable.userId, oldestMembership!.userId)); 127 | } 128 | 129 | return true; 130 | }); 131 | } 132 | 133 | async changeActiveTeam(teamId: number, userId: number) { 134 | await db.update(userTable).set({ activeTeamId: teamId }).where(eq(userTable.id, userId)); 135 | } 136 | } 137 | 138 | export const teamRepository = new TeamRepository(); 139 | -------------------------------------------------------------------------------- /src/lib/server/teams/roles.ts: -------------------------------------------------------------------------------- 1 | export enum TeamRole { 2 | Admin = 'admin', 3 | Member = 'member' 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { applyAction } from '$app/forms'; 2 | import type { SubmitFunction } from '@sveltejs/kit'; 3 | import { type ClassValue, clsx } from 'clsx'; 4 | import { toast } from 'svelte-sonner'; 5 | import { cubicOut } from 'svelte/easing'; 6 | import type { TransitionConfig } from 'svelte/transition'; 7 | import { twMerge } from 'tailwind-merge'; 8 | 9 | export function cn(...inputs: ClassValue[]) { 10 | return twMerge(clsx(inputs)); 11 | } 12 | 13 | type FlyAndScaleParams = { 14 | y?: number; 15 | x?: number; 16 | start?: number; 17 | duration?: number; 18 | }; 19 | 20 | export const flyAndScale = ( 21 | node: Element, 22 | params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 } 23 | ): TransitionConfig => { 24 | const style = getComputedStyle(node); 25 | const transform = style.transform === 'none' ? '' : style.transform; 26 | 27 | const scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => { 28 | const [minA, maxA] = scaleA; 29 | const [minB, maxB] = scaleB; 30 | 31 | const percentage = (valueA - minA) / (maxA - minA); 32 | const valueB = percentage * (maxB - minB) + minB; 33 | 34 | return valueB; 35 | }; 36 | 37 | const styleToString = (style: Record): string => { 38 | return Object.keys(style).reduce((str, key) => { 39 | if (style[key] === undefined) return str; 40 | return str + `${key}:${style[key]};`; 41 | }, ''); 42 | }; 43 | 44 | return { 45 | duration: params.duration ?? 200, 46 | delay: 0, 47 | css: (t) => { 48 | const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]); 49 | const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]); 50 | const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]); 51 | 52 | return styleToString({ 53 | transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`, 54 | opacity: t 55 | }); 56 | }, 57 | easing: cubicOut 58 | }; 59 | }; 60 | 61 | export const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); 62 | 63 | export const formHandler: SubmitFunction = ({ formElement, submitter }) => { 64 | const icon = formElement.querySelector('button > svg'); 65 | const loadingIcon = document.createElement('span'); 66 | loadingIcon.innerHTML = loadingIconSvg; 67 | 68 | if (icon) { 69 | icon.remove(); 70 | } 71 | 72 | submitter?.setAttribute('disabled', 'true'); 73 | submitter?.insertAdjacentElement('afterbegin', loadingIcon); 74 | 75 | return ({ result, update }) => { 76 | update({ reset: false, invalidateAll: true }); 77 | applyAction(result); 78 | 79 | submitter?.removeAttribute('disabled'); 80 | loadingIcon.remove(); 81 | 82 | if (icon) { 83 | submitter?.insertAdjacentElement('afterbegin', icon); 84 | } 85 | 86 | if (result.type === 'success') { 87 | if (result.data?.message) { 88 | toast.success(result.data.message); 89 | } 90 | } else if (result.type === 'error') { 91 | if (result.error?.errorMessage) { 92 | toast.error(result.error?.errorMessage); 93 | } 94 | } else if (result.type === 'failure') { 95 | if (result.data?.errorMessage) { 96 | toast.error(result.data?.errorMessage); 97 | } 98 | } 99 | }; 100 | }; 101 | 102 | export const getBreadcrumb = (path: string, custom?: string) => { 103 | const tokens = path.split('/').filter((t) => t !== ''); 104 | let tokenPath = ''; 105 | 106 | const crumbs = tokens.map((t, i) => { 107 | tokenPath += '/' + t; 108 | t = t.charAt(0).toUpperCase() + t.slice(1); 109 | 110 | return { label: i === tokens.length - 1 && custom ? custom : t, href: tokenPath }; 111 | }); 112 | 113 | return crumbs; 114 | }; 115 | 116 | const loadingIconSvg = ` 118 | 121 | 124 | 125 | `; 126 | -------------------------------------------------------------------------------- /src/routes/(dashboard)/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { authRepository } from '$lib/server/auth/repository.js'; 2 | import { teamRepository } from '$lib/server/teams/repository.js'; 3 | 4 | export const load = async (event) => { 5 | return { 6 | user: event.locals.user, 7 | team: event.locals.team, 8 | teams: teamRepository.findTeamsByUserId(event.locals.user!.id) 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /src/routes/(dashboard)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 |
    15 | 16 |
    17 |
    20 | 21 | 22 | 23 | 24 |
    25 |
    26 | {@render children()} 27 |
    28 |
    29 |
    30 | -------------------------------------------------------------------------------- /src/routes/(dashboard)/admin/users/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { registerDto } from '$lib/server/auth/dtos.js'; 2 | import { authRepository } from '$lib/server/auth/repository'; 3 | import { error, fail, type RequestEvent } from '@sveltejs/kit'; 4 | 5 | const create = async (event: RequestEvent) => { 6 | const { data, error } = await registerDto.safeParseAsync(event.locals.body); 7 | 8 | if (error) { 9 | return fail(400, { errors: error.flatten().fieldErrors }); 10 | } 11 | 12 | const user = await authRepository.createUser(data.email, data.password, data.fullName); 13 | 14 | if (!user) { 15 | return fail(400, { 16 | errors: { email: ['Email already exists'], fullName: undefined, password: undefined } 17 | }); 18 | } 19 | }; 20 | 21 | export const load = async (event) => { 22 | const page = Number(event.url.searchParams.get('page')); 23 | const limit = Number(event.url.searchParams.get('limit')); 24 | 25 | if (isNaN(page) || isNaN(limit)) { 26 | return error(400, 'Invalid page or limit'); 27 | } 28 | 29 | return { 30 | users: authRepository.findUsers(page, limit) 31 | }; 32 | }; 33 | 34 | export const actions = { create }; 35 | -------------------------------------------------------------------------------- /src/routes/(dashboard)/admin/users/+page.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |
    21 |
    22 |

    Users

    23 | List of all users registered in the system 24 |
    25 | {#if selected.length === 0} 26 | 27 | {:else} 28 | 31 | {/if} 32 |
    33 | 34 | 35 | {#snippet row(user: any)} 36 | 37 |
    {user.fullName}
    38 | 39 |
    40 | 43 | 48 | 51 | 52 | 53 | 56 | 57 | 58 | {/snippet} 59 |
    60 | 61 | 66 |
    72 |
    73 | 74 | 81 |
    82 |
    83 | 84 | 90 |
    91 |
    92 | 93 | 101 |
    102 | 103 |
    104 |
    105 | -------------------------------------------------------------------------------- /src/routes/(dashboard)/admin/users/[id]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { updateCredenitlasDto } from '$lib/server/auth/dtos.js'; 2 | import { authRepository } from '$lib/server/auth/repository.js'; 3 | import { Role } from '$lib/server/auth/roles.js'; 4 | import { fail, redirect, type RequestEvent } from '@sveltejs/kit'; 5 | 6 | const destroy = async (event: RequestEvent) => { 7 | await authRepository.deleteUser(Number(event.params.id)); 8 | 9 | return { 10 | message: 'User has been deleted' 11 | }; 12 | }; 13 | 14 | const update = async (event: RequestEvent) => { 15 | if (event.locals.user?.role !== Role.Admin) { 16 | return redirect(307, '/home'); 17 | } 18 | 19 | const { data, error } = await updateCredenitlasDto.safeParseAsync(event.locals.body); 20 | 21 | if (error) { 22 | return fail(400, { errors: error.flatten().fieldErrors }); 23 | } 24 | 25 | const user = await authRepository.updateUser( 26 | Number(event.params.id), 27 | data.email, 28 | data.fullName, 29 | data.role, 30 | data.password 31 | ); 32 | 33 | if (!user) { 34 | return redirect(307, '/users'); 35 | } 36 | 37 | return { 38 | message: "User's credentials have been updated" 39 | }; 40 | }; 41 | 42 | const verifyEmail = async (event: RequestEvent) => { 43 | await authRepository.verifyEmailWithoutCode(Number(event.params.id)); 44 | 45 | return { 46 | message: 'User email has been verified' 47 | }; 48 | }; 49 | 50 | export const load = async (event) => { 51 | const user = await authRepository.findUserById(Number(event.params.id)); 52 | 53 | if (!user) { 54 | return redirect(307, '/users'); 55 | } 56 | 57 | return { 58 | user, 59 | sessions: authRepository.findSessionsByUserId(Number(event.params.id)), 60 | currentSession: event.cookies.get('auth_session'), 61 | crumb: user.fullName 62 | }; 63 | }; 64 | 65 | export const actions = { update, verifyEmail, destroy }; 66 | -------------------------------------------------------------------------------- /src/routes/(dashboard)/admin/users/[id]/+page.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | 34 | 35 |
    36 |
    37 |

    Edit User

    38 | Modify user's informations 39 |
    40 | 41 | 42 | 43 | 46 | 47 | 48 | {#if !data.user.isVerified} 49 | 50 |
    55 | 56 |
    57 |
    58 | {/if} 59 | 60 |
    61 | 62 |
    63 |
    64 |
    65 |
    66 |
    67 | 68 | 69 | 70 |
    71 | 72 | 73 | VS 74 | 75 |
    76 | {data.user.fullName} 77 | 78 | Member since {formatDate(data.user.createdAt!, 'dd MMM yyyy')} 79 | 80 |
    81 |
    82 |
    83 |
    84 | 85 | 86 | 87 | Credentials 88 | Change user's email or password 89 | 90 | 91 |
    98 |
    99 | 100 | 108 | 109 |
    110 | 111 | {data.user.isVerified ? 'Verified' : 'Not Verified'} 112 | 113 |
    114 |
    115 |
    116 | 117 | 124 |
    125 |
    126 | 127 | (selectedRole = e!.value)} 129 | selected={{ value: data.user.role, label: capitalize(data.user.role) }} 130 | > 131 | 132 | 133 | 134 | 135 | User 136 | Admin 137 | 138 | 139 | 140 |
    141 |
    142 | 143 | 151 |
    152 |
    153 |
    154 | 155 | 156 | 157 |
    158 | 159 | 160 | 161 | Sessions 162 | Location and device used in each user session 163 | 164 | 165 | 172 | {#snippet row(session: any)} 173 | 185 | 186 |
    {session.ip}
    187 |
    188 | 196 | 199 | 200 | 216 | 217 | {/snippet} 218 |
    219 |
    220 |
    221 | -------------------------------------------------------------------------------- /src/routes/(dashboard)/admin/users/[id]/session/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { lucia } from '$lib/server/auth/adapter.js'; 2 | import { deleteSessionDto } from '$lib/server/auth/dtos.js'; 3 | import { Role } from '$lib/server/auth/roles.js'; 4 | import { fail, redirect, type RequestEvent } from '@sveltejs/kit'; 5 | 6 | const destroy = async (event: RequestEvent) => { 7 | const { data, error } = await deleteSessionDto.safeParseAsync(event.locals.body); 8 | 9 | if (error) { 10 | return fail(400, { errors: error.flatten().fieldErrors }); 11 | } 12 | 13 | await lucia.invalidateSession(data.sessionId); 14 | }; 15 | 16 | export const actions = { destroy }; 17 | -------------------------------------------------------------------------------- /src/routes/(dashboard)/home/+page.svelte: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /src/routes/(dashboard)/settings/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
    7 |

    Settings

    8 | Modify your account settings here. 9 |
    10 | 11 | 12 | 13 | Apperence 14 | Toggle between dark and light theme 15 | 16 | 17 |
    18 | 25 | Dark Mode 26 |
    27 |
    28 | 35 | Light Mode 36 |
    37 |
    38 | 49 | System 50 |
    51 |
    52 |
    53 | -------------------------------------------------------------------------------- /src/routes/(dashboard)/teams/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { createTeamDto } from '$lib/server/teams/dtos.js'; 2 | import { teamRepository } from '$lib/server/teams/repository.js'; 3 | import { fail, type RequestEvent } from '@sveltejs/kit'; 4 | 5 | const create = async (event: RequestEvent) => { 6 | const { data, error } = await createTeamDto.safeParseAsync(event.locals.body); 7 | 8 | if (error) { 9 | return fail(400, { errors: error.flatten().fieldErrors }); 10 | } 11 | 12 | await teamRepository.createTeam(data.name, event.locals.user!.id); 13 | 14 | return { 15 | message: 'Team has been created' 16 | }; 17 | }; 18 | 19 | export const actions = { create }; 20 | -------------------------------------------------------------------------------- /src/routes/(dashboard)/teams/+page.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 |
    18 |
    19 |

    Teams

    20 | List of all of your teams 21 |
    22 | 23 |
    24 | 25 | 26 | {#snippet row(team: any)} 27 | 28 |
    29 | {team.name} 34 | {team.name} 35 | {#if data.team?.id === team.id} 36 | Current 37 | {/if} 38 |
    39 |
    40 | 41 | 42 | 45 | 46 | 47 | {/snippet} 48 |
    49 | 50 | 55 |
    61 |
    62 | 63 | 70 |
    71 | 72 |
    73 |
    74 | -------------------------------------------------------------------------------- /src/routes/(dashboard)/teams/[id]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { authRepository } from '$lib/server/auth/repository.js'; 2 | import { addTeamMemberDto, removeTeamMemberDto, updateTeamDto } from '$lib/server/teams/dtos.js'; 3 | import { teamRepository } from '$lib/server/teams/repository.js'; 4 | import { TeamRole } from '$lib/server/teams/roles.js'; 5 | import { fail, redirect, type RequestEvent } from '@sveltejs/kit'; 6 | 7 | const update = async (event: RequestEvent) => { 8 | const teamId = Number(event.params.id); 9 | const isMember = await teamRepository.isTeamMember(teamId, event.locals.user!.id); 10 | 11 | if (isNaN(teamId) || !isMember) { 12 | return redirect(307, '/teams'); 13 | } 14 | 15 | const { data, error } = await updateTeamDto.safeParseAsync(event.locals.body); 16 | 17 | if (error) { 18 | return fail(400, { errors: error.flatten().fieldErrors }); 19 | } 20 | 21 | await teamRepository.updateTeam(teamId, data.name); 22 | return { message: 'Team has been updated' }; 23 | }; 24 | 25 | const change = async (event: RequestEvent) => { 26 | const teamId = Number(event.params.id); 27 | const isMember = await teamRepository.isTeamMember(teamId, event.locals.user!.id); 28 | 29 | if (isNaN(teamId) || !isMember) { 30 | return redirect(307, '/teams'); 31 | } 32 | 33 | await teamRepository.changeActiveTeam(teamId, event.locals.user!.id); 34 | return { message: 'Active team has been changed' }; 35 | }; 36 | 37 | const removeMember = async (event: RequestEvent) => { 38 | const teamId = Number(event.params.id); 39 | const membership = await teamRepository.findTeamMemberByUserId(teamId, event.locals.user!.id); 40 | 41 | if (isNaN(teamId) || membership?.role !== TeamRole.Admin) { 42 | return redirect(307, '/teams'); 43 | } 44 | 45 | const { data, error } = await removeTeamMemberDto.safeParseAsync(event.locals.body); 46 | 47 | if (error) { 48 | return fail(400, { errors: error.flatten().fieldErrors }); 49 | } 50 | 51 | const isRemoved = await teamRepository.removeTeamMember(teamId, data.userId); 52 | 53 | if (!isRemoved) { 54 | return fail(400, { errorMessage: 'Team must have at least one member' }); 55 | } 56 | 57 | return { message: 'Member has been removed' }; 58 | }; 59 | 60 | const addMember = async (event: RequestEvent) => { 61 | const teamId = Number(event.params.id); 62 | const membership = await teamRepository.findTeamMemberByUserId(teamId, event.locals.user!.id); 63 | 64 | if (isNaN(teamId) || membership?.role !== TeamRole.Admin) { 65 | return redirect(307, '/teams'); 66 | } 67 | 68 | const { data, error } = await addTeamMemberDto.safeParseAsync(event.locals.body); 69 | 70 | if (error) { 71 | return fail(400, { errors: error.flatten().fieldErrors }); 72 | } 73 | 74 | const user = await authRepository.findUserByEmail(data.email); 75 | 76 | if (!user) { 77 | return fail(400, { errorMessage: 'User not found' }); 78 | } 79 | 80 | const isAdded = await teamRepository.addTeamMember(teamId, user?.id, data.role); 81 | 82 | if (!isAdded) { 83 | return fail(400, { errorMessage: 'User is already a member of the team' }); 84 | } 85 | 86 | return { message: 'Member has been added' }; 87 | }; 88 | 89 | export const load = async (event) => { 90 | const teamId = Number(event.params.id); 91 | const page = Number(event.url.searchParams.get('page') ?? 1); 92 | const isMember = await teamRepository.isTeamMember(teamId, event.locals.user!.id); 93 | 94 | if (isNaN(teamId) || page <= 0 || !isMember) { 95 | return redirect(307, '/teams'); 96 | } 97 | 98 | const team = await teamRepository.findTeamById(teamId); 99 | 100 | if (!team) { 101 | return redirect(307, '/teams'); 102 | } 103 | 104 | return { 105 | team, 106 | members: teamRepository.findTeamMembers(teamId, page), 107 | crumb: team!.name 108 | }; 109 | }; 110 | 111 | export const actions = { change, update, removeMember, addMember }; 112 | -------------------------------------------------------------------------------- /src/routes/(dashboard)/teams/[id]/+page.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | 32 | 33 |
    34 |
    35 |

    Edit Team

    36 | Manage team information and it's members 37 |
    38 | 39 |
    40 | 41 | 42 | 43 | Informations 44 | Change team name and picture 45 | 46 | 47 |
    54 |
    55 | 56 | 64 |
    65 |
    66 | 67 | 68 | VS 69 | 70 |
    71 | 72 | 73 |
    74 |
    75 |
    76 |
    77 | 78 | 79 | 80 |
    81 | 82 | 83 | 84 | Members 85 | Manage team members 86 | 87 | 88 | 95 | {#snippet row(member: any)} 96 | 97 |
    {member.fullName}
    98 | 99 |
    100 | 103 | 106 | 107 | 123 | 124 | {/snippet} 125 |
    126 |
    127 |
    128 | 129 | 134 |
    140 |
    141 | 142 | 143 |
    144 |
    145 | 146 | (selectedRole = e!.value as string)}> 147 | 148 | 149 | 150 | 151 | Member 152 | Admin 153 | 154 | 155 | 156 |
    157 | 158 |
    159 |
    160 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | {@render children()} 9 | -------------------------------------------------------------------------------- /src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | 3 | export const load = async () => { 4 | return redirect(307, '/home'); 5 | }; 6 | -------------------------------------------------------------------------------- /src/routes/auth/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | 3 | export const load = (event) => { 4 | if (event.locals.user && event.locals.user.isVerified) { 5 | return redirect(307, '/home'); 6 | } 7 | 8 | if ( 9 | event.locals.user && 10 | !event.locals.user.isVerified && 11 | event.url.pathname !== '/auth/verify-email' 12 | ) { 13 | return redirect(307, '/auth/verify-email'); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/routes/auth/+layout.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 |
    22 | 31 | {@render children()} 32 |
    33 | -------------------------------------------------------------------------------- /src/routes/auth/login/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { lucia } from '$lib/server/auth/adapter.js'; 2 | import { loginDto } from '$lib/server/auth/dtos.js'; 3 | import { authRepository } from '$lib/server/auth/repository.js'; 4 | import type { RequestEvent } from './$types.js'; 5 | import { fail, redirect } from '@sveltejs/kit'; 6 | 7 | async function login(event: RequestEvent) { 8 | const { data, error } = await loginDto.safeParseAsync(event.locals.body); 9 | 10 | if (error) { 11 | return fail(400, { errors: error.flatten().fieldErrors }); 12 | } 13 | 14 | const user = await authRepository.findUserByEmailAndPassword(data.email, data.password); 15 | 16 | if (!user) { 17 | return fail(400, { errors: { email: [true], password: ['Invalid email or password'] } }); 18 | } 19 | 20 | let countryCode = 'Unknown'; 21 | 22 | try { 23 | const ipResponse = await event.fetch(`https://api.country.is/${event.getClientAddress()}`); 24 | const { country, error } = await ipResponse.json(); 25 | countryCode = error ? 'Unknown' : country; 26 | } catch (e) { 27 | // 28 | } 29 | 30 | const session = await lucia.createSession(user.id, { 31 | ip: event.getClientAddress(), 32 | userAgent: event.request.headers.get('user-agent') ?? 'Unknown', 33 | country: countryCode 34 | }); 35 | 36 | const sessionCookie = lucia.createSessionCookie(session.id); 37 | 38 | event.cookies.set(sessionCookie.name, sessionCookie.value, { 39 | path: '.', 40 | ...sessionCookie.attributes 41 | }); 42 | 43 | if (!user.isVerified) { 44 | return redirect(307, '/auth/verify-email'); 45 | } 46 | 47 | return redirect(307, '/home'); 48 | } 49 | 50 | export const actions = { default: login }; 51 | -------------------------------------------------------------------------------- /src/routes/auth/login/+page.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
    13 |
    14 |

    Login into your account

    15 |

    Enter your credentials below to access your account

    16 |
    17 |
    18 |
    19 |
    20 |
    21 | 22 | 32 |
    33 |
    34 | 35 | 45 |
    46 | 47 | 48 |
    49 |
    50 |
    51 | 54 |
    55 | Or continue with 56 |
    57 |
    58 |
    59 | 60 | 61 |
    62 |
    63 | 64 | Don't have an account Sign Up 65 | 66 |
    67 | -------------------------------------------------------------------------------- /src/routes/auth/logout/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { lucia } from '$lib/server/auth/adapter.js'; 2 | import { redirect, type RequestEvent } from '@sveltejs/kit'; 3 | 4 | const logout = async (event: RequestEvent) => { 5 | const sessionId = event.cookies.get(lucia.sessionCookieName); 6 | 7 | if (!sessionId) { 8 | return redirect(307, '/auth/login'); 9 | } 10 | 11 | await lucia.invalidateSession(sessionId); 12 | }; 13 | 14 | export const actions = { default: logout }; 15 | -------------------------------------------------------------------------------- /src/routes/auth/oauth/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { oauthService } from '$lib/server/auth/services/oauth-service.js'; 2 | import { redirect } from '@sveltejs/kit'; 3 | 4 | export const load = async (event) => { 5 | const provider = event.url.searchParams.get('provider') ?? ''; 6 | 7 | if (!oauthService.isSupportedProvider(provider)) { 8 | return redirect(307, '/auth/login'); 9 | } 10 | 11 | const { state, codeVerifier, redirectUrl } = await oauthService.getRedirectUrl(provider); 12 | 13 | event.cookies.set('oauth_state', state, { 14 | path: '/', 15 | secure: import.meta.env.PROD, 16 | httpOnly: true, 17 | maxAge: 60 * 10, 18 | sameSite: 'lax' 19 | }); 20 | 21 | event.cookies.set('code_verifier', codeVerifier, { 22 | path: '/', 23 | secure: import.meta.env.PROD, 24 | httpOnly: true, 25 | maxAge: 60 * 10, 26 | sameSite: 'lax' 27 | }); 28 | 29 | event.cookies.set('oauth_provider', provider, { 30 | path: '/', 31 | secure: import.meta.env.PROD, 32 | httpOnly: true, 33 | maxAge: 60 * 10, 34 | sameSite: 'lax' 35 | }); 36 | 37 | if (!redirectUrl) { 38 | return redirect(307, '/auth/login'); 39 | } 40 | 41 | return redirect(302, redirectUrl.toString()); 42 | }; 43 | -------------------------------------------------------------------------------- /src/routes/auth/oauth/callback/+server.ts: -------------------------------------------------------------------------------- 1 | import { lucia } from '$lib/server/auth/adapter.js'; 2 | import { authRepository } from '$lib/server/auth/repository.js'; 3 | import { oauthService } from '$lib/server/auth/services/oauth-service.js'; 4 | import { error, redirect } from '@sveltejs/kit'; 5 | 6 | export const GET = async (event) => { 7 | const code = event.url.searchParams.get('code'); 8 | const state = event.url.searchParams.get('state'); 9 | const storedState = event.cookies.get('oauth_state') ?? null; 10 | const codeVerifier = event.cookies.get('code_verifier') ?? null; 11 | const provider = event.cookies.get('oauth_provider') ?? null; 12 | 13 | if (!code || !codeVerifier || !state || !storedState || !provider) { 14 | return error(404); 15 | } 16 | 17 | const tokens = await oauthService.validateAuthorizationCode(provider, code, codeVerifier); 18 | 19 | if (!tokens) { 20 | return error(404); 21 | } 22 | 23 | const profile = await oauthService.getProfile(provider, tokens.accessToken, fetch); 24 | if (!profile) { 25 | return error(404); 26 | } 27 | 28 | const oauthConnection = await authRepository.createOAuthConnection(provider, profile); 29 | 30 | if (!oauthConnection) { 31 | console.error('Failed to create OAuth connection'); 32 | return error(400); 33 | } 34 | 35 | let countryCode = 'Unknown'; 36 | 37 | try { 38 | const ipResponse = await event.fetch(`https://api.country.is/${event.getClientAddress()}`); 39 | const { country, error } = await ipResponse.json(); 40 | countryCode = error ? 'Unknown' : country; 41 | } catch (e) { 42 | // 43 | } 44 | 45 | const session = await lucia.createSession(oauthConnection.userId, { 46 | ip: event.getClientAddress(), 47 | userAgent: event.request.headers.get('user-agent') ?? 'Unknown', 48 | country: countryCode 49 | }); 50 | 51 | const sessionCookie = lucia.createSessionCookie(session.id); 52 | 53 | event.cookies.set(sessionCookie.name, sessionCookie.value, { 54 | path: '.', 55 | ...sessionCookie.attributes 56 | }); 57 | 58 | return redirect(307, '/home'); 59 | }; 60 | -------------------------------------------------------------------------------- /src/routes/auth/register/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { lucia } from '$lib/server/auth/adapter.js'; 2 | import { registerDto } from '$lib/server/auth/dtos.js'; 3 | import { authRepository } from '$lib/server/auth/repository.js'; 4 | import type { RequestEvent } from './$types.js'; 5 | import { fail, redirect } from '@sveltejs/kit'; 6 | 7 | async function register(event: RequestEvent) { 8 | const { data, error } = await registerDto.safeParseAsync(event.locals.body); 9 | 10 | if (error) { 11 | return fail(400, { errors: error.flatten().fieldErrors }); 12 | } 13 | 14 | const isUnique = await authRepository.isUniqueEmail(data.email); 15 | 16 | if (!isUnique) { 17 | return fail(400, { errors: { email: [true], password: ['Email already in use'] } }); 18 | } 19 | 20 | const user = await authRepository.createUser(data.email, data.password, data.fullName); 21 | 22 | let countryCode = 'Unknown'; 23 | 24 | try { 25 | const ipResponse = await event.fetch(`https://api.country.is/${event.getClientAddress()}`); 26 | const { country, error } = await ipResponse.json(); 27 | countryCode = error ? 'Unknown' : country; 28 | } catch (e) { 29 | // 30 | } 31 | 32 | const session = await lucia.createSession(user.id, { 33 | ip: event.getClientAddress(), 34 | userAgent: event.request.headers.get('user-agent') ?? 'Unknown', 35 | country: countryCode 36 | }); 37 | 38 | const sessionCookie = lucia.createSessionCookie(session.id); 39 | 40 | event.cookies.set(sessionCookie.name, sessionCookie.value, { 41 | path: '.', 42 | ...sessionCookie.attributes 43 | }); 44 | 45 | if (!user.isVerified) { 46 | return redirect(307, '/auth/verify-email'); 47 | } 48 | 49 | return redirect(307, '/home'); 50 | } 51 | 52 | export const actions = { default: register }; 53 | -------------------------------------------------------------------------------- /src/routes/auth/register/+page.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    12 |
    13 |

    Create your account

    14 |

    Enter your details below to create your account

    15 |
    16 |
    17 |
    21 | async ({ update }) => 22 | update({ reset: false })} 23 | > 24 |
    25 |
    26 | 27 | 37 |
    38 |
    39 | 40 | 50 |
    51 |
    52 | 53 | 63 |
    64 | 65 | 66 | Already have an account? Sign In 67 | 68 |
    69 |
    70 |
    71 |
    72 | -------------------------------------------------------------------------------- /src/routes/auth/verify-email/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { verifyEmailDto } from '$lib/server/auth/dtos.js'; 2 | import { authRepository } from '$lib/server/auth/repository.js'; 3 | import { sendEmailQueue } from '$lib/server/email/queues.js'; 4 | import { fail, redirect, type RequestEvent } from '@sveltejs/kit'; 5 | import { addMinutes } from 'date-fns'; 6 | 7 | const verify = async (event: RequestEvent) => { 8 | const { data, error } = await verifyEmailDto.safeParseAsync(event.locals.body); 9 | 10 | if (error) { 11 | return fail(400, { errors: error.flatten().fieldErrors }); 12 | } 13 | 14 | const didVerify = await authRepository.verifyEmail(event.locals.user!.id, data.code); 15 | 16 | if (!didVerify) { 17 | return fail(400, { errors: { code: ['Verification code is expired'] } }); 18 | } 19 | 20 | return redirect(307, '/home'); 21 | }; 22 | 23 | const resend = async (event: RequestEvent) => { 24 | const token = await authRepository.createVerificationCode( 25 | event.locals.user!.id, 26 | addMinutes(new Date(), 30) 27 | ); 28 | 29 | sendEmailQueue.add('send-email', { 30 | to: event.locals.user?.email, 31 | text: `Your verification code is ${token.id}.` 32 | }); 33 | }; 34 | 35 | export const actions = { verify, resend }; 36 | -------------------------------------------------------------------------------- /src/routes/auth/verify-email/+page.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    12 |
    13 |

    Verify your account

    14 |

    15 | Check your email and input the verification code bellow 16 |

    17 |
    18 |
    19 |
    20 |
    21 |
    22 | 23 | 33 |
    34 | 35 | 36 | Didn't receive any email? 37 | 38 | 39 |
    40 |
    41 |
    42 |
    43 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mynameisvasco/sveltekit-boilerplate/41005d81c9389a5600f13c4fc357131d19a9b65e/static/favicon.png -------------------------------------------------------------------------------- /static/img/dark-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mynameisvasco/sveltekit-boilerplate/41005d81c9389a5600f13c4fc357131d19a9b65e/static/img/dark-mode.png -------------------------------------------------------------------------------- /static/img/light-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mynameisvasco/sveltekit-boilerplate/41005d81c9389a5600f13c4fc357131d19a9b65e/static/img/light-mode.png -------------------------------------------------------------------------------- /static/img/logos-chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mynameisvasco/sveltekit-boilerplate/41005d81c9389a5600f13c4fc357131d19a9b65e/static/img/logos-chrome.png -------------------------------------------------------------------------------- /static/img/logos-edge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mynameisvasco/sveltekit-boilerplate/41005d81c9389a5600f13c4fc357131d19a9b65e/static/img/logos-edge.png -------------------------------------------------------------------------------- /static/img/logos-firefox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mynameisvasco/sveltekit-boilerplate/41005d81c9389a5600f13c4fc357131d19a9b65e/static/img/logos-firefox.png -------------------------------------------------------------------------------- /static/img/logos-safari.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mynameisvasco/sveltekit-boilerplate/41005d81c9389a5600f13c4fc357131d19a9b65e/static/img/logos-safari.png -------------------------------------------------------------------------------- /static/img/logos-unknown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mynameisvasco/sveltekit-boilerplate/41005d81c9389a5600f13c4fc357131d19a9b65e/static/img/logos-unknown.png -------------------------------------------------------------------------------- /static/img/system-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mynameisvasco/sveltekit-boilerplate/41005d81c9389a5600f13c4fc357131d19a9b65e/static/img/system-mode.png -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | preprocess: vitePreprocess(), 7 | 8 | kit: { 9 | adapter: adapter() 10 | } 11 | }; 12 | 13 | export default config; 14 | -------------------------------------------------------------------------------- /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: 'Inter' 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": ["unplugin-icons/types/svelte"] 14 | } 15 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 16 | // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files 17 | // 18 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 19 | // from the referenced tsconfig.json - TypeScript does not merge them in 20 | } 21 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import Icons from 'unplugin-icons/vite'; 3 | import { defineConfig } from 'vite'; 4 | 5 | export default defineConfig({ 6 | optimizeDeps: { 7 | exclude: ['@node-rs/argon2/'] 8 | }, 9 | plugins: [ 10 | sveltekit(), 11 | Icons({ 12 | compiler: 'svelte' 13 | }) 14 | ] 15 | }); 16 | --------------------------------------------------------------------------------