diff --git a/apps/web/migrations/0001_small_sandman.sql b/apps/web/migrations/0001_small_sandman.sql
new file mode 100644
index 00000000..8e700869
--- /dev/null
+++ b/apps/web/migrations/0001_small_sandman.sql
@@ -0,0 +1 @@
+ALTER TABLE `project_settings` DROP COLUMN `updated_at`;
\ No newline at end of file
diff --git a/apps/web/migrations/meta/0001_snapshot.json b/apps/web/migrations/meta/0001_snapshot.json
new file mode 100644
index 00000000..0cae8705
--- /dev/null
+++ b/apps/web/migrations/meta/0001_snapshot.json
@@ -0,0 +1,888 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "607db8e6-13b2-4689-9b43-19cb098c9d46",
+ "prevId": "9d8306a6-bd63-4a9a-95ff-88847f376235",
+ "tables": {
+ "accounts": {
+ "name": "accounts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "accounts_user_id_idx": {
+ "name": "accounts_user_id_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "provider_compound_idx": {
+ "name": "provider_compound_idx",
+ "columns": [
+ "provider_id",
+ "account_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "accounts_user_id_users_id_fk": {
+ "name": "accounts_user_id_users_id_fk",
+ "tableFrom": "accounts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invitations": {
+ "name": "invitations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "org_email_idx": {
+ "name": "org_email_idx",
+ "columns": [
+ "organization_id",
+ "email"
+ ],
+ "isUnique": false
+ },
+ "invitations_expires_at_idx": {
+ "name": "invitations_expires_at_idx",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "invitations_organization_id_organizations_id_fk": {
+ "name": "invitations_organization_id_organizations_id_fk",
+ "tableFrom": "invitations",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitations_inviter_id_users_id_fk": {
+ "name": "invitations_inviter_id_users_id_fk",
+ "tableFrom": "invitations",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "members": {
+ "name": "members",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "org_user_idx": {
+ "name": "org_user_idx",
+ "columns": [
+ "organization_id",
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "members_organization_id_organizations_id_fk": {
+ "name": "members_organization_id_organizations_id_fk",
+ "tableFrom": "members",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "members_user_id_users_id_fk": {
+ "name": "members_user_id_users_id_fk",
+ "tableFrom": "members",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "organizations": {
+ "name": "organizations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "plan": {
+ "name": "plan",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'free'"
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "organizations_slug_unique": {
+ "name": "organizations_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "organizations_api_key_unique": {
+ "name": "organizations_api_key_unique",
+ "columns": [
+ "api_key"
+ ],
+ "isUnique": true
+ },
+ "slug_idx": {
+ "name": "slug_idx",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": false
+ },
+ "org_api_key_idx": {
+ "name": "org_api_key_idx",
+ "columns": [
+ "api_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "project_settings": {
+ "name": "project_settings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cache": {
+ "name": "cache",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "context": {
+ "name": "context",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "temperature": {
+ "name": "temperature",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "instructions": {
+ "name": "instructions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "memory": {
+ "name": "memory",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "grammar": {
+ "name": "grammar",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "project_idx": {
+ "name": "project_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "project_settings_project_id_projects_id_fk": {
+ "name": "project_settings_project_id_projects_id_fk",
+ "tableFrom": "project_settings",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "projects": {
+ "name": "projects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "org_idx": {
+ "name": "org_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ },
+ "slug_org_idx": {
+ "name": "slug_org_idx",
+ "columns": [
+ "slug",
+ "organization_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "projects_organization_id_organizations_id_fk": {
+ "name": "projects_organization_id_organizations_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sessions": {
+ "name": "sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "token_idx": {
+ "name": "token_idx",
+ "columns": [
+ "token"
+ ],
+ "isUnique": false
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ },
+ "users_api_key_unique": {
+ "name": "users_api_key_unique",
+ "columns": [
+ "api_key"
+ ],
+ "isUnique": true
+ },
+ "email_idx": {
+ "name": "email_idx",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verifications": {
+ "name": "verifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "identifier_idx": {
+ "name": "identifier_idx",
+ "columns": [
+ "identifier"
+ ],
+ "isUnique": false
+ },
+ "verifications_expires_at_idx": {
+ "name": "verifications_expires_at_idx",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json
index 783e6737..fdbf5511 100644
--- a/apps/web/migrations/meta/_journal.json
+++ b/apps/web/migrations/meta/_journal.json
@@ -8,6 +8,13 @@
"when": 1736332317965,
"tag": "0000_sad_red_shift",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "6",
+ "when": 1736414139537,
+ "tag": "0001_small_sandman",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/apps/web/package.json b/apps/web/package.json
index 6a7e9375..2929a394 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -35,7 +35,7 @@
"@radix-ui/react-tooltip": "^1.1.6",
"@react-email/components": "0.0.32",
"@react-email/font": "^0.0.9",
- "@tanstack/react-query": "^5.62.16",
+ "@tanstack/react-query": "^5.63.0",
"@trpc/client": "^11.0.0-rc.688",
"@trpc/react-query": "^11.0.0-rc.688",
"@trpc/server": "^11.0.0-rc.688",
@@ -47,7 +47,7 @@
"drizzle-orm": "^0.38.3",
"input-otp": "^1.4.2",
"lucide-react": "^0.469.0",
- "motion": "^11.16.0",
+ "motion": "^11.16.1",
"next": "15.1.4",
"next-international": "^1.3.1",
"next-safe-action": "^7.10.2",
@@ -66,7 +66,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
- "@tanstack/react-query-devtools": "^5.62.16",
+ "@tanstack/react-query-devtools": "^5.63.0",
"@types/node": "^22",
"@types/react": "^19",
"react-email": "3.0.5",
diff --git a/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[team]/[project]/page.tsx b/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[organization]/[project]/page.tsx
similarity index 66%
rename from apps/web/src/app/[locale]/(dashboard)/(sidebar)/[team]/[project]/page.tsx
rename to apps/web/src/app/[locale]/(dashboard)/(sidebar)/[organization]/[project]/page.tsx
index dc8b2421..82afcafb 100644
--- a/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[team]/[project]/page.tsx
+++ b/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[organization]/[project]/page.tsx
@@ -1,10 +1,10 @@
import { Activity } from "@/components/activity";
-import { TranslationsChart } from "@/components/charts/translations";
+import { AnalyticsChart } from "@/components/charts/analytics";
export default function Page() {
return (
-
+
diff --git a/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[organization]/[project]/settings/page.tsx b/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[organization]/[project]/settings/page.tsx
new file mode 100644
index 00000000..457d352e
--- /dev/null
+++ b/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[organization]/[project]/settings/page.tsx
@@ -0,0 +1,27 @@
+import { Settings } from "@/components/settings";
+import { HydrateClient, trpc } from "@/trpc/server";
+
+export default async function Page({
+ params,
+}: {
+ params: Promise<{ organization: string; project: string }>;
+}) {
+ const { organization, project } = await params;
+
+ trpc.project.getBySlug.prefetch({
+ slug: project,
+ organizationId: organization,
+ });
+
+ trpc.user.me.prefetch();
+
+ trpc.organization.getById.prefetch({
+ organizationId: organization,
+ });
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[team]/[project]/tuning/page.tsx b/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[organization]/[project]/tuning/page.tsx
similarity index 100%
rename from apps/web/src/app/[locale]/(dashboard)/(sidebar)/[team]/[project]/tuning/page.tsx
rename to apps/web/src/app/[locale]/(dashboard)/(sidebar)/[organization]/[project]/tuning/page.tsx
diff --git a/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[team]/[project]/settings/page.tsx b/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[team]/[project]/settings/page.tsx
deleted file mode 100644
index 2d4849fa..00000000
--- a/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[team]/[project]/settings/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { Settings } from "@/components/settings";
-
-export default function Page() {
- return
;
-}
diff --git a/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[team]/team/page.tsx b/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[team]/team/page.tsx
deleted file mode 100644
index d6726784..00000000
--- a/apps/web/src/app/[locale]/(dashboard)/(sidebar)/[team]/team/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import TeamManagement from "@/components/team-management";
-
-export default function Page() {
- return
;
-}
diff --git a/apps/web/src/app/[locale]/(dashboard)/(sidebar)/layout.tsx b/apps/web/src/app/[locale]/(dashboard)/(sidebar)/layout.tsx
index e870ad4d..e8d8da16 100644
--- a/apps/web/src/app/[locale]/(dashboard)/(sidebar)/layout.tsx
+++ b/apps/web/src/app/[locale]/(dashboard)/(sidebar)/layout.tsx
@@ -1,5 +1,6 @@
import { ComingSoon } from "@/components/coming-soon";
import { Header } from "@/components/dashboard/header";
+import { GlobalModals } from "@/components/modals";
import { Sidebar } from "@/components/sidebar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { Toaster } from "@/components/ui/sonner";
@@ -33,11 +34,12 @@ export default async function Layout({
{children}
{process.env.NODE_ENV !== "development" &&
}
-
+
+
);
diff --git a/apps/web/src/app/[locale]/(dashboard)/invite/[invitationId]/page.tsx b/apps/web/src/app/[locale]/(dashboard)/invite/[invitationId]/page.tsx
new file mode 100644
index 00000000..c5094758
--- /dev/null
+++ b/apps/web/src/app/[locale]/(dashboard)/invite/[invitationId]/page.tsx
@@ -0,0 +1,56 @@
+import { acceptInvitation } from "@/lib/auth/queries";
+import { getSession } from "@/lib/session";
+import { trpc } from "@/trpc/server";
+import { redirect } from "next/navigation";
+
+/**
+ * Page component for handling team invitations
+ *
+ * Flow:
+ * 1. Validates invitation ID exists
+ * 2. Checks if user is logged in, redirects to login if not
+ * 3. Accepts the invitation
+ * 4. Redirects to organization dashboard if successful
+ */
+export default async function Page({
+ params,
+}: {
+ params: { invitationId: string };
+}) {
+ const { invitationId } = await params;
+
+ // Validate invitation ID exists
+ if (!invitationId) {
+ redirect("/login");
+ }
+
+ // Check if user is logged in
+ const session = await getSession();
+
+ if (!session) {
+ // Redirect to login with return URL to invitation page
+ redirect(
+ `/login?redirect=${encodeURIComponent(`/invite/${invitationId}`)}`,
+ );
+ }
+
+ // Accept the invitation
+ const invite = await acceptInvitation(invitationId);
+
+ if (!invite) {
+ redirect("/login");
+ }
+
+ // Get organization details
+ const organization = await trpc.organization.getById({
+ organizationId: invite.invitation.organizationId,
+ });
+
+ // Redirect to organization dashboard if found
+ if (organization) {
+ redirect(`/${organization.slug}/default`);
+ }
+
+ // Fallback redirect to login
+ redirect("/login");
+}
diff --git a/apps/web/src/app/[locale]/layout.tsx b/apps/web/src/app/[locale]/layout.tsx
index ce671889..20a382c6 100644
--- a/apps/web/src/app/[locale]/layout.tsx
+++ b/apps/web/src/app/[locale]/layout.tsx
@@ -1,9 +1,9 @@
import "../globals.css";
-import { I18nProviderClient } from "@/locales/client";
import { OpenPanelComponent } from "@openpanel/nextjs";
import type { Metadata } from "next";
import { Geist_Mono } from "next/font/google";
+import { Providers } from "./providers";
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
@@ -27,14 +27,14 @@ export default async function RootLayout({
return (
+ {children}
+
-
- {children}
);
diff --git a/apps/web/src/app/[locale]/not-found.tsx b/apps/web/src/app/[locale]/not-found.tsx
new file mode 100644
index 00000000..04644546
--- /dev/null
+++ b/apps/web/src/app/[locale]/not-found.tsx
@@ -0,0 +1,13 @@
+import Link from "next/link";
+
+export default function NotFound() {
+ return (
+
+
Not Found
+
Could not find requested resource
+
+ Return Home
+
+
+ );
+}
diff --git a/apps/web/src/app/[locale]/providers.tsx b/apps/web/src/app/[locale]/providers.tsx
new file mode 100644
index 00000000..90dc885a
--- /dev/null
+++ b/apps/web/src/app/[locale]/providers.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import { I18nProviderClient } from "@/locales/client";
+import type { ReactNode } from "react";
+
+type ProviderProps = {
+ locale: string;
+ children: ReactNode;
+};
+
+export function Providers({ locale, children }: ProviderProps) {
+ return {children};
+}
diff --git a/apps/web/src/app/api/trpc/[trpc] /route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts
similarity index 100%
rename from apps/web/src/app/api/trpc/[trpc] /route.ts
rename to apps/web/src/app/api/trpc/[trpc]/route.ts
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 1f3971e9..59ca767d 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -62,7 +62,7 @@ body {
--muted-foreground: 0 0% 33.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
- --destructive: 0 62.8% 30.6%;
+ --destructive: 359 100% 60%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
@@ -111,3 +111,7 @@ body {
.scroll-smooth {
scroll-behavior: smooth;
}
+
+.scrollbar-hide::-webkit-scrollbar {
+ display: none;
+}
diff --git a/apps/web/src/components/charts/translations.tsx b/apps/web/src/components/charts/analytics.tsx
similarity index 98%
rename from apps/web/src/components/charts/translations.tsx
rename to apps/web/src/components/charts/analytics.tsx
index 457ff013..a12df038 100644
--- a/apps/web/src/components/charts/translations.tsx
+++ b/apps/web/src/components/charts/analytics.tsx
@@ -31,7 +31,7 @@ const chartData = [
{ month: "Oct", value: 5000 },
];
-export function TranslationsChart() {
+export function AnalyticsChart() {
const t = useI18n();
return (
diff --git a/apps/web/src/components/copy-input.tsx b/apps/web/src/components/copy-input.tsx
index d8937cde..235be392 100644
--- a/apps/web/src/components/copy-input.tsx
+++ b/apps/web/src/components/copy-input.tsx
@@ -20,18 +20,17 @@ export function CopyInput({ value, className, ...props }: CopyInputProps) {
};
return (
-
+
@@ -62,82 +64,55 @@ export function TeamSelector() {
{t("teamSelector.teams")}
-
+
{organizations.map((org) => (
{
- router.push(`/${org?.id}/${projectSlug}`);
- setOpen(false);
+ handleSetActiveTeam(org.id);
}}
>
{org?.name}
{
e.stopPropagation();
setOpen(false);
router.push(
- `/${currentTeam?.id}/${projectSlug}/settings?tab=team`,
+ `/${org?.id}/${projectSlug}/settings?tab=team`,
);
}}
/>
{currentTeam?.id === org?.id && (
-
+
)}
))}
-
+
+
setCreateTeamModalOpen(true)}
+ type="button"
+ className="flex w-full items-center gap-2 p-2 pb-3 text-xs text-secondary hover:text-primary transition-colors duration-100 border-t border-border"
+ >
+
+ {t("teamSelector.createTeam")}
+
{t("teamSelector.project")}
-
+
{currentTeam?.projects.map((project) => (
{
router.push(`/${currentTeam?.id}/${project.slug}`);
setOpen(false);
@@ -146,7 +121,7 @@ export function TeamSelector() {
{project.name}
{
e.stopPropagation();
setOpen(false);
@@ -156,49 +131,21 @@ export function TeamSelector() {
}}
/>
{projectSlug === project.slug && (
-
+
)}
))}
-
+
+
setCreateProjectModalOpen(true)}
+ type="button"
+ className="flex w-full items-center gap-2 p-2 pb-3 text-xs text-secondary hover:text-primary transition-colors duration-100 border-t border-border"
+ >
+
+ {t("teamSelector.addProject")}
+
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
index b5b7eb01..502385d1 100644
--- a/apps/web/src/components/ui/dialog.tsx
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
{children}
-
+
Close
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx
index 46e5c22e..2839da62 100644
--- a/apps/web/src/components/ui/input.tsx
+++ b/apps/web/src/components/ui/input.tsx
@@ -8,7 +8,7 @@ const Input = React.forwardRef>(
{
- router.push("/");
+ router.push("/login");
},
},
});
@@ -51,13 +51,15 @@ export function UserMenu() {
{session?.user?.email}
-
+
{t("userMenu.account")}
diff --git a/apps/web/src/db/queries/delete.ts b/apps/web/src/db/queries/delete.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/apps/web/src/db/queries/insert.ts b/apps/web/src/db/queries/insert.ts
deleted file mode 100644
index 9f2534ec..00000000
--- a/apps/web/src/db/queries/insert.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { db } from "@/db";
-import { members, organizations, projects, sessions } from "@/db/schema";
-import { createId } from "@paralleldrive/cuid2";
-import { eq } from "drizzle-orm";
-import slugify from "slugify";
-
-export async function createDefaultOrganization(user: {
- id: string;
- name: string;
-}) {
- // Create default organization for new user
- const org = await db
- .insert(organizations)
- .values({
- name: user.name,
- slug: `${slugify(user.name, { lower: true })}-${createId().slice(0, 8)}`,
- })
- .returning()
- .get();
-
- // Add user as member of organization
- await db.insert(members).values({
- userId: user.id,
- organizationId: org.id,
- role: "owner",
- });
-
- // Create default project for new organization
- await db.insert(projects).values({
- name: "Default",
- organizationId: org.id,
- slug: "default",
- });
-
- // Set active organization for new user's session
- await db
- .update(sessions)
- .set({ activeOrganizationId: org.id })
- .where(eq(sessions.userId, user.id));
-
- return org;
-}
diff --git a/apps/web/src/db/queries/organization.ts b/apps/web/src/db/queries/organization.ts
new file mode 100644
index 00000000..ca6661c9
--- /dev/null
+++ b/apps/web/src/db/queries/organization.ts
@@ -0,0 +1,202 @@
+import { db } from "@/db";
+import {
+ invitations,
+ members,
+ organizations,
+ projects,
+ sessions,
+ users,
+} from "@/db/schema";
+import { createId } from "@paralleldrive/cuid2";
+import { eq } from "drizzle-orm";
+import slugify from "slugify";
+
+export async function createDefaultOrganization(user: {
+ id: string;
+ name: string;
+}) {
+ // Create default organization for new user
+ const org = await db
+ .insert(organizations)
+ .values({
+ name: user.name,
+ slug: `${slugify(user.name, { lower: true })}-${createId().slice(0, 8)}`,
+ })
+ .returning()
+ .get();
+
+ // Add user as member of organization
+ await db.insert(members).values({
+ userId: user.id,
+ organizationId: org.id,
+ role: "owner",
+ });
+
+ // Create default project for new organization
+ await db.insert(projects).values({
+ name: "Default",
+ organizationId: org.id,
+ slug: "default",
+ });
+
+ // Set active organization for new user's session
+ await db
+ .update(sessions)
+ .set({ activeOrganizationId: org.id })
+ .where(eq(sessions.userId, user.id));
+
+ return org;
+}
+
+export const createOrganization = async ({
+ name,
+ userId,
+}: {
+ name: string;
+ userId: string;
+}) => {
+ const org = await db
+ .insert(organizations)
+ .values({
+ name,
+ slug: `${slugify(name, { lower: true })}-${createId().slice(0, 8)}`,
+ })
+ .returning()
+ .get();
+
+ if (org) {
+ await db.insert(members).values({
+ userId,
+ organizationId: org.id,
+ role: "owner",
+ });
+
+ await db.insert(projects).values({
+ name: "Default",
+ organizationId: org.id,
+ slug: "default",
+ });
+ }
+
+ return org;
+};
+
+export const deleteOrganization = async (id: string) => {
+ return db
+ .delete(organizations)
+ .where(eq(organizations.id, id))
+ .returning()
+ .get();
+};
+
+export const getDefaultOrganization = async (userId: string) => {
+ return db
+ .select()
+ .from(members)
+ .where(eq(members.userId, userId))
+ .leftJoin(organizations, eq(organizations.id, members.organizationId))
+ .limit(1)
+ .get();
+};
+
+export const getAllOrganizationsWithProjects = async (userId: string) => {
+ const orgs = await db
+ .select()
+ .from(organizations)
+ .leftJoin(members, eq(members.organizationId, organizations.id))
+ .where(eq(members.userId, userId))
+ .all();
+
+ const orgsWithProjects = await Promise.all(
+ orgs.map(async (org) => {
+ const orgProjects = await db
+ .select()
+ .from(projects)
+ .where(eq(projects.organizationId, org.organizations.id))
+ .all();
+
+ return {
+ ...org.organizations,
+ projects: orgProjects,
+ };
+ }),
+ );
+
+ return orgsWithProjects;
+};
+
+export const getOrganization = async (id: string) => {
+ return db.select().from(organizations).where(eq(organizations.id, id)).get();
+};
+
+export const getProjectById = async (projectId: string) => {
+ return db.select().from(projects).where(eq(projects.id, projectId)).get();
+};
+
+export const updateOrganization = async ({
+ id,
+ name,
+ logo,
+}: {
+ id: string;
+ name: string;
+ logo?: string;
+}) => {
+ return db
+ .update(organizations)
+ .set({
+ name,
+ logo,
+ })
+ .where(eq(organizations.id, id))
+ .returning()
+ .get();
+};
+
+export const getOrganizationMembers = async (organizationId: string) => {
+ return db
+ .select({
+ id: members.id,
+ role: members.role,
+ user: {
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ image: users.image,
+ },
+ })
+ .from(members)
+ .innerJoin(users, eq(members.userId, users.id))
+ .where(eq(members.organizationId, organizationId))
+ .all();
+};
+
+export const getOrganizationInvites = async (organizationId: string) => {
+ return db
+ .select({
+ id: invitations.id,
+ email: invitations.email,
+ role: invitations.role,
+ status: invitations.status,
+ expiresAt: invitations.expiresAt,
+ inviter: {
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ image: users.image,
+ },
+ })
+ .from(invitations)
+ .innerJoin(users, eq(invitations.inviterId, users.id))
+ .where(eq(invitations.organizationId, organizationId))
+ .all();
+};
+
+export const deleteOrganizationInvite = async (inviteId: string) => {
+ console.log("deleteOrganizationInvite", inviteId);
+ return db
+ .delete(invitations)
+ .where(eq(invitations.id, inviteId))
+ .returning()
+ .get();
+};
diff --git a/apps/web/src/db/queries/project-settings.ts b/apps/web/src/db/queries/project-settings.ts
new file mode 100644
index 00000000..149f401e
--- /dev/null
+++ b/apps/web/src/db/queries/project-settings.ts
@@ -0,0 +1,75 @@
+import { eq } from "drizzle-orm";
+import { db } from "..";
+import { projectSettings } from "../schema";
+
+export const getProjectSettings = async (projectId: string) => {
+ return db
+ .select()
+ .from(projectSettings)
+ .where(eq(projectSettings.projectId, projectId))
+ .get();
+};
+
+export const updateProjectSettings = async ({
+ projectId,
+ cache,
+ context,
+ temperature,
+ instructions,
+ memory,
+ grammar,
+}: {
+ projectId: string;
+ cache?: boolean;
+ context?: boolean;
+ temperature?: number;
+ instructions?: string;
+ memory?: boolean;
+ grammar?: boolean;
+}) => {
+ return db
+ .update(projectSettings)
+ .set({
+ cache,
+ context,
+ temperature,
+ instructions,
+ memory,
+ grammar,
+ })
+ .where(eq(projectSettings.projectId, projectId))
+ .returning()
+ .get();
+};
+
+export const createProjectSettings = async ({
+ projectId,
+ cache = true,
+ context = true,
+ temperature = 0,
+ instructions,
+ memory = true,
+ grammar = true,
+}: {
+ projectId: string;
+ cache?: boolean;
+ context?: boolean;
+ temperature?: number;
+ instructions?: string;
+ memory?: boolean;
+ grammar?: boolean;
+}) => {
+ return db
+ .insert(projectSettings)
+ .values({
+ projectId,
+ cache,
+ context,
+ temperature,
+ instructions,
+ memory,
+ grammar,
+ })
+ .returning()
+ .get();
+};
diff --git a/apps/web/src/db/queries/project.ts b/apps/web/src/db/queries/project.ts
new file mode 100644
index 00000000..fb9f7f28
--- /dev/null
+++ b/apps/web/src/db/queries/project.ts
@@ -0,0 +1,85 @@
+import { and, eq } from "drizzle-orm";
+import slugify from "slugify";
+import { db } from "..";
+import { projects } from "../schema";
+
+export const createProject = async ({
+ name,
+ organizationId,
+}: {
+ name: string;
+ organizationId: string;
+}) => {
+ return db
+ .insert(projects)
+ .values({
+ name,
+ organizationId,
+ slug: slugify(name, { lower: true }),
+ })
+ .returning()
+ .get();
+};
+
+export const updateProject = async ({
+ slug,
+ name,
+ organizationId,
+}: {
+ slug: string;
+ name: string;
+ organizationId: string;
+}) => {
+ return db
+ .update(projects)
+ .set({ name })
+ .where(
+ and(eq(projects.slug, slug), eq(projects.organizationId, organizationId)),
+ )
+ .returning()
+ .get();
+};
+
+export const deleteProject = async ({
+ slug,
+ organizationId,
+}: {
+ slug: string;
+ organizationId: string;
+}) => {
+ return db
+ .delete(projects)
+ .where(
+ and(eq(projects.slug, slug), eq(projects.organizationId, organizationId)),
+ )
+ .returning()
+ .get();
+};
+
+export const getProjectBySlug = async ({
+ slug,
+ organizationId,
+}: {
+ slug: string;
+ organizationId: string;
+}) => {
+ return db
+ .select()
+ .from(projects)
+ .where(
+ and(eq(projects.slug, slug), eq(projects.organizationId, organizationId)),
+ )
+ .get();
+};
+
+export const getProjectByOrganizationId = async ({
+ organizationId,
+}: {
+ organizationId: string;
+}) => {
+ return db
+ .select()
+ .from(projects)
+ .where(eq(projects.organizationId, organizationId))
+ .get();
+};
diff --git a/apps/web/src/db/queries/select.ts b/apps/web/src/db/queries/select.ts
deleted file mode 100644
index 34fb8f2a..00000000
--- a/apps/web/src/db/queries/select.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { db } from "@/db";
-import { eq } from "drizzle-orm";
-import { members, organizations, projects } from "../schema";
-
-export const getDefaultOrganization = async (userId: string) => {
- return await db
- .select()
- .from(members)
- .where(eq(members.userId, userId))
- .leftJoin(organizations, eq(organizations.id, members.organizationId))
- .limit(1)
- .get();
-};
-
-export const getAllOrganizationsWithProjects = async (userId: string) => {
- const orgs = await db
- .select()
- .from(organizations)
- .leftJoin(members, eq(members.organizationId, organizations.id))
- .where(eq(members.userId, userId))
- .all();
-
- const orgsWithProjects = await Promise.all(
- orgs.map(async (org) => {
- const orgProjects = await db
- .select()
- .from(projects)
- .where(eq(projects.organizationId, org.organizations.id))
- .all();
-
- return {
- ...org.organizations,
- projects: orgProjects,
- };
- }),
- );
-
- return orgsWithProjects;
-};
diff --git a/apps/web/src/db/queries/update.ts b/apps/web/src/db/queries/update.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/apps/web/src/db/queries/user.ts b/apps/web/src/db/queries/user.ts
new file mode 100644
index 00000000..4e82b8d0
--- /dev/null
+++ b/apps/web/src/db/queries/user.ts
@@ -0,0 +1,32 @@
+import { eq } from "drizzle-orm";
+import { db } from "..";
+import { users } from "../schema";
+
+export const updateUser = async ({
+ id,
+ name,
+ email,
+}: {
+ id: string;
+ name?: string;
+ email?: string;
+}) => {
+ return db
+ .update(users)
+ .set({
+ ...(name && { name }),
+ ...(email && { email }),
+ updatedAt: new Date(),
+ })
+ .where(eq(users.id, id))
+ .returning()
+ .get();
+};
+
+export const deleteUser = async ({ id }: { id: string }) => {
+ return db.delete(users).where(eq(users.id, id)).returning().get();
+};
+
+export const getUserById = async ({ id }: { id: string }) => {
+ return db.select().from(users).where(eq(users.id, id)).get();
+};
diff --git a/apps/web/src/db/schema.ts b/apps/web/src/db/schema.ts
index e8f33501..be65d98a 100644
--- a/apps/web/src/db/schema.ts
+++ b/apps/web/src/db/schema.ts
@@ -27,9 +27,7 @@ export const users = sqliteTable(
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
},
- (table) => ({
- emailIdx: index("email_idx").on(table.email),
- }),
+ (table) => [index("email_idx").on(table.email)],
);
export const sessions = sqliteTable(
@@ -51,11 +49,11 @@ export const sessions = sqliteTable(
.references(() => users.id, { onDelete: "cascade" }),
activeOrganizationId: text("active_organization_id"),
},
- (table) => ({
- userIdIdx: index("user_id_idx").on(table.userId),
- tokenIdx: index("token_idx").on(table.token),
- expiresAtIdx: index("expires_at_idx").on(table.expiresAt),
- }),
+ (table) => [
+ index("user_id_idx").on(table.userId),
+ index("token_idx").on(table.token),
+ index("expires_at_idx").on(table.expiresAt),
+ ],
);
export const accounts = sqliteTable(
@@ -85,13 +83,10 @@ export const accounts = sqliteTable(
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
},
- (table) => ({
- userIdIdx: index("accounts_user_id_idx").on(table.userId),
- providerCompoundIdx: index("provider_compound_idx").on(
- table.providerId,
- table.accountId,
- ),
- }),
+ (table) => [
+ index("accounts_user_id_idx").on(table.userId),
+ index("provider_compound_idx").on(table.providerId, table.accountId),
+ ],
);
export const verifications = sqliteTable(
@@ -108,10 +103,10 @@ export const verifications = sqliteTable(
),
updatedAt: integer("updated_at", { mode: "timestamp" }),
},
- (table) => ({
- identifierIdx: index("identifier_idx").on(table.identifier),
- expiresAtIdx: index("verifications_expires_at_idx").on(table.expiresAt),
- }),
+ (table) => [
+ index("identifier_idx").on(table.identifier),
+ index("verifications_expires_at_idx").on(table.expiresAt),
+ ],
);
export const projects = sqliteTable(
@@ -131,13 +126,10 @@ export const projects = sqliteTable(
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }),
},
- (table) => ({
- orgIdx: index("org_idx").on(table.organizationId),
- slugOrgIdx: uniqueIndex("slug_org_idx").on(
- table.slug,
- table.organizationId,
- ),
- }),
+ (table) => [
+ index("org_idx").on(table.organizationId),
+ uniqueIndex("slug_org_idx").on(table.slug, table.organizationId),
+ ],
);
export const organizations = sqliteTable(
@@ -161,10 +153,10 @@ export const organizations = sqliteTable(
.$defaultFn(() => new Date()),
metadata: text("metadata"),
},
- (table) => ({
- slugIdx: index("slug_idx").on(table.slug),
- apiKeyIdx: index("org_api_key_idx").on(table.apiKey),
- }),
+ (table) => [
+ index("slug_idx").on(table.slug),
+ index("org_api_key_idx").on(table.apiKey),
+ ],
);
export const members = sqliteTable(
@@ -184,9 +176,7 @@ export const members = sqliteTable(
.notNull()
.$defaultFn(() => new Date()),
},
- (table) => ({
- orgUserIdx: index("org_user_idx").on(table.organizationId, table.userId),
- }),
+ (table) => [index("org_user_idx").on(table.organizationId, table.userId)],
);
export const invitations = sqliteTable(
@@ -206,10 +196,10 @@ export const invitations = sqliteTable(
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
},
- (table) => ({
- orgEmailIdx: index("org_email_idx").on(table.organizationId, table.email),
- expiresAtIdx: index("invitations_expires_at_idx").on(table.expiresAt),
- }),
+ (table) => [
+ index("org_email_idx").on(table.organizationId, table.email),
+ index("invitations_expires_at_idx").on(table.expiresAt),
+ ],
);
export const projectSettings = sqliteTable(
@@ -230,9 +220,6 @@ export const projectSettings = sqliteTable(
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
- updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
},
- (table) => ({
- projectIdx: index("project_idx").on(table.projectId),
- }),
+ (table) => [index("project_idx").on(table.projectId)],
);
diff --git a/apps/web/src/emails/templates/invite.tsx b/apps/web/src/emails/templates/invite.tsx
new file mode 100644
index 00000000..57e644ae
--- /dev/null
+++ b/apps/web/src/emails/templates/invite.tsx
@@ -0,0 +1,82 @@
+import { Footer } from "@/emails/components/footer";
+import { Logo } from "@/emails/components/logo";
+import { OutlineButton } from "@/emails/components/outline-button";
+import {
+ Body,
+ Container,
+ Font,
+ Head,
+ Html,
+ Link,
+ Preview,
+ Section,
+ Tailwind,
+ Text,
+} from "@react-email/components";
+
+export default function InviteEmail({
+ invitedByUsername = "Viktor",
+ invitedByEmail = "viktor@languine.ai",
+ teamName = "Languine",
+ inviteLink = "https://languine.ai/invite",
+}: {
+ invitedByUsername: string;
+ invitedByEmail: string;
+ teamName: string;
+ inviteLink: string;
+}) {
+ return (
+
+
+
+
+ You've been invited to join {teamName} on Languine
+
+
+
+
+
+
+ Hi there! {invitedByUsername} ({invitedByEmail}) has invited you
+ to join {teamName} on Languine.
+
+
+
+
+ Accept Invitation
+
+
+
+
+
+ If you have any questions or didn't expect this invitation,
+ please contact{" "}
+
+ support@languine.ai
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/hooks/use-create-project-modal.ts b/apps/web/src/hooks/use-create-project-modal.ts
new file mode 100644
index 00000000..a8a297d0
--- /dev/null
+++ b/apps/web/src/hooks/use-create-project-modal.ts
@@ -0,0 +1,13 @@
+import { parseAsBoolean, useQueryState } from "nuqs";
+
+export function useCreateProjectModal() {
+ const [open, setOpen] = useQueryState(
+ "create-project",
+ parseAsBoolean.withDefault(false),
+ );
+
+ return {
+ open,
+ setOpen,
+ };
+}
diff --git a/apps/web/src/hooks/use-create-team-modal.ts b/apps/web/src/hooks/use-create-team-modal.ts
new file mode 100644
index 00000000..2ee2b557
--- /dev/null
+++ b/apps/web/src/hooks/use-create-team-modal.ts
@@ -0,0 +1,13 @@
+import { parseAsBoolean, useQueryState } from "nuqs";
+
+export function useCreateTeamModal() {
+ const [open, setOpen] = useQueryState(
+ "create-team",
+ parseAsBoolean.withDefault(false),
+ );
+
+ return {
+ open,
+ setOpen,
+ };
+}
diff --git a/apps/web/src/hooks/use-invite-modal.ts b/apps/web/src/hooks/use-invite-modal.ts
new file mode 100644
index 00000000..565af196
--- /dev/null
+++ b/apps/web/src/hooks/use-invite-modal.ts
@@ -0,0 +1,13 @@
+import { parseAsBoolean, useQueryState } from "nuqs";
+
+export function useInviteModal() {
+ const [open, setOpen] = useQueryState(
+ "invite",
+ parseAsBoolean.withDefault(false),
+ );
+
+ return {
+ open,
+ setOpen,
+ };
+}
diff --git a/apps/web/src/hooks/use-mobile.ts b/apps/web/src/hooks/use-mobile.ts
new file mode 100644
index 00000000..a93d5839
--- /dev/null
+++ b/apps/web/src/hooks/use-mobile.ts
@@ -0,0 +1,21 @@
+import * as React from "react";
+
+const MOBILE_BREAKPOINT = 768;
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(
+ undefined,
+ );
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ };
+ mql.addEventListener("change", onChange);
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ return () => mql.removeEventListener("change", onChange);
+ }, []);
+
+ return !!isMobile;
+}
diff --git a/apps/web/src/hooks/use-mobile.tsx b/apps/web/src/hooks/use-mobile.tsx
deleted file mode 100644
index 2b0fe1df..00000000
--- a/apps/web/src/hooks/use-mobile.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import * as React from "react"
-
-const MOBILE_BREAKPOINT = 768
-
-export function useIsMobile() {
- const [isMobile, setIsMobile] = React.useState(undefined)
-
- React.useEffect(() => {
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
- const onChange = () => {
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
- }
- mql.addEventListener("change", onChange)
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
- return () => mql.removeEventListener("change", onChange)
- }, [])
-
- return !!isMobile
-}
diff --git a/apps/web/src/lib/auth/middleware.ts b/apps/web/src/lib/auth/middleware.ts
index 93d0cf01..82ab363d 100644
--- a/apps/web/src/lib/auth/middleware.ts
+++ b/apps/web/src/lib/auth/middleware.ts
@@ -4,7 +4,7 @@ import { cache } from "react";
type SessionData = {
user: User;
- session: Session;
+ session: Session & { activeOrganizationId: string };
};
export const getSessionFromRequest = cache(
@@ -18,6 +18,7 @@ export const getSessionFromRequest = cache(
},
).then(async (res) => {
if (!res.ok) return null;
+
return res.json();
});
},
diff --git a/apps/web/src/lib/auth/queries.ts b/apps/web/src/lib/auth/queries.ts
new file mode 100644
index 00000000..6941c638
--- /dev/null
+++ b/apps/web/src/lib/auth/queries.ts
@@ -0,0 +1,15 @@
+import { headers } from "next/headers";
+import { authClient } from "./client";
+
+export async function acceptInvitation(invitationId: string) {
+ const { data } = await authClient.organization.acceptInvitation(
+ {
+ invitationId,
+ },
+ {
+ headers: await headers(),
+ },
+ );
+
+ return data;
+}
diff --git a/apps/web/src/lib/auth/server.ts b/apps/web/src/lib/auth/server.ts
index bd8ab57f..d90ab420 100644
--- a/apps/web/src/lib/auth/server.ts
+++ b/apps/web/src/lib/auth/server.ts
@@ -1,12 +1,16 @@
import { db } from "@/db";
-import { createDefaultOrganization } from "@/db/queries/insert";
-import { getDefaultOrganization } from "@/db/queries/select";
+import {
+ createDefaultOrganization,
+ getDefaultOrganization,
+} from "@/db/queries/organization";
import * as schema from "@/db/schema";
+import InviteEmail from "@/emails/templates/invite";
import WelcomeEmail from "@/emails/templates/welcome";
import { resend } from "@/lib/resend";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { organization } from "better-auth/plugins";
+import { getAppUrl } from "../url";
export const auth = betterAuth({
database: drizzleAdapter(db, {
@@ -30,7 +34,7 @@ export const auth = betterAuth({
user: {
create: {
after: async (user) => {
- const org = await createDefaultOrganization(user);
+ await createDefaultOrganization(user);
// Send welcome email to new user
try {
@@ -67,5 +71,27 @@ export const auth = betterAuth({
maxAge: 5 * 60,
},
},
- plugins: [organization()],
+ plugins: [
+ organization({
+ async sendInvitationEmail(data) {
+ const inviteLink = `${getAppUrl()}/invite/${data.id}`;
+
+ try {
+ await resend.emails.send({
+ from: "Languine ",
+ to: data.email,
+ subject: `You've been invited to join ${data.organization.name} on Languine`,
+ react: InviteEmail({
+ invitedByUsername: data.inviter.user.name,
+ invitedByEmail: data.inviter.user.email,
+ teamName: data.organization.name,
+ inviteLink,
+ }),
+ });
+ } catch (error) {
+ console.error("Error sending welcome email", error);
+ }
+ },
+ }),
+ ],
});
diff --git a/apps/web/src/locales/en.ts b/apps/web/src/locales/en.ts
index cdbf71f3..30313fc5 100644
--- a/apps/web/src/locales/en.ts
+++ b/apps/web/src/locales/en.ts
@@ -98,7 +98,7 @@ export default {
team: "Team Settings",
},
teamSelector: {
- addProject: "Add project",
+ addProject: "Create project",
createProjectTitle: "Create a new project",
projectNamePlaceholder: "Project name",
createProjectButton: "Create project",
@@ -147,6 +147,7 @@ export default {
"This action cannot be undone. Please type DELETE to confirm.",
placeholder: "Type DELETE to confirm",
confirm: "Confirm Delete",
+ cancel: "Cancel",
},
},
pipeline: {
@@ -158,11 +159,15 @@ export default {
settings: {
saved: "Settings saved",
savedDescription: "Your changes have been saved successfully",
+ error: "Something went wrong",
+ errorDescription: "Please try again or contact support",
+ addTeam: "Create team",
tabs: {
project: "Project",
account: "Account",
team: "Team",
},
+ addProject: "Create project",
project: {
name: {
title: "Project Name",
@@ -180,6 +185,7 @@ export default {
button: "Delete Project",
},
},
+ save: "Save",
team: {
name: {
title: "Team Name",
@@ -198,6 +204,7 @@ export default {
placeholder: "Team API Key",
},
members: {
+ invite: "Invite member",
title: "Members",
pendingInvitations: "Pending Invitations",
filterPlaceholder: "Filter members...",
@@ -205,10 +212,16 @@ export default {
date: "Date",
selectAll: "{count} selected",
noPendingInvitations: "No pending invitations",
- inviteMembers: "Invite team members to collaborate",
+ noResults: "No members found",
+ tryDifferentSearch: "Try a different search",
+ inviteMembers: "Invite members to collaborate",
+ deleteInvite: "Delete Invite",
+ deleteInviteDescription: "Delete the invitation",
+ invitedBy: "Invited by {name}",
+ deleteInviteSuccess: "Invitation deleted",
+ deleteInviteError: "Failed to delete invitation",
roles: {
owner: "Owner",
- admin: "Admin",
member: "Member",
},
dateSort: {
@@ -302,4 +315,39 @@ export default {
},
cta: "Start automating",
},
+ createTeam: {
+ teamName: "Team Name",
+ teamNamePlaceholder: "Enter team name",
+ createTeamButton: "Create team",
+ cancel: "Cancel",
+ createTeamDescription:
+ "Create a new team to manage your projects and collaborators.",
+ },
+ createProject: {
+ createProjectTitle: "Create a new project",
+ projectNamePlaceholder: "Enter project name",
+ createProjectButton: "Create project",
+ cancel: "Cancel",
+ createProjectDescription:
+ "Create a new project to start automating your localization.",
+ },
+ invite: {
+ inviteMember: "Invite member",
+ inviteDescription: "Invite a member to your team",
+ emailLabel: "Email",
+ emailPlaceholder: "Enter email",
+ cancel: "Cancel",
+ sendInvite: "Send invite",
+ success: {
+ title: "Invitation sent",
+ description: "Successfully sent invitation to {email}",
+ },
+ error: {
+ title: "Error",
+ description: "Failed to send invitation. Please try again.",
+ },
+ validation: {
+ invalidEmail: "Please enter a valid email",
+ },
+ },
} as const;
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts
index 5b0797d0..d8e5a666 100644
--- a/apps/web/src/middleware.ts
+++ b/apps/web/src/middleware.ts
@@ -1,7 +1,7 @@
import { createI18nMiddleware } from "next-international/middleware";
import { type NextRequest, NextResponse } from "next/server";
import languineConfig from "../languine.config";
-import { getDefaultOrganization } from "./db/queries/select";
+import { getProjectByOrganizationId } from "./db/queries/project";
import { getSessionFromRequest } from "./lib/auth/middleware";
const I18nMiddleware = createI18nMiddleware({
@@ -14,17 +14,22 @@ export async function middleware(request: NextRequest) {
// Only proceed with organization check for login path
if (request.nextUrl.pathname.includes("/login")) {
- const session = await getSessionFromRequest();
+ const data = await getSessionFromRequest();
- if (!session?.user.id) {
+ if (!data?.user.id) {
return i18nResponse;
}
- const data = await getDefaultOrganization(session?.user.id);
+ if (data.session?.activeOrganizationId) {
+ const project = await getProjectByOrganizationId({
+ organizationId: data.session.activeOrganizationId,
+ });
- if (data?.organizations) {
return NextResponse.redirect(
- new URL(`/${data.organizations.slug}/default`, request.url),
+ new URL(
+ `/${data.session.activeOrganizationId}/${project?.slug || "default"}`,
+ request.url,
+ ),
);
}
}
diff --git a/apps/web/src/trpc/client.tsx b/apps/web/src/trpc/client.tsx
index 1a1914b7..0cd4bc4d 100644
--- a/apps/web/src/trpc/client.tsx
+++ b/apps/web/src/trpc/client.tsx
@@ -1,5 +1,5 @@
"use client";
-// ^-- to make sure we can mount the Provider from a server component
+
import type { QueryClient } from "@tanstack/react-query";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
@@ -35,6 +35,7 @@ function getUrl() {
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return "http://localhost:3000";
})();
+
return `${base}/api/trpc`;
}
@@ -43,11 +44,8 @@ export function TRPCProvider(
children: React.ReactNode;
}>,
) {
- // NOTE: Avoid useState when initializing the query client if you don't
- // have a suspense boundary between this and the code that may
- // suspend because React will throw away the client on the initial
- // render if it suspends and there is no boundary
const queryClient = getQueryClient();
+
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
@@ -63,13 +61,10 @@ export function TRPCProvider(
url: getUrl(),
}),
}),
- // httpBatchLink({
- // transformer: superjson,
- // url: getUrl(),
- // }),
],
}),
);
+
return (
diff --git a/apps/web/src/trpc/init.ts b/apps/web/src/trpc/init.ts
index 09a61875..337ebdca 100644
--- a/apps/web/src/trpc/init.ts
+++ b/apps/web/src/trpc/init.ts
@@ -4,29 +4,31 @@ import { cache } from "react";
import superjson from "superjson";
export const createTRPCContext = cache(async () => {
- return {};
+ const session = await getSession();
+
+ return {
+ user: session.data?.user,
+ };
});
-const t = initTRPC.context().create({
+export const t = initTRPC.context().create({
transformer: superjson,
});
export const createCallerFactory = t.createCallerFactory;
-// Base router and procedure helpers
export const createTRPCRouter = t.router;
-export const baseProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(async (opts) => {
- const session = await getSession();
+ const { user } = opts.ctx;
- if (!session.data?.user) {
+ if (!user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return opts.next({
ctx: {
- user: session.data.user,
+ user,
},
});
});
diff --git a/apps/web/src/trpc/middleware/organization.ts b/apps/web/src/trpc/middleware/organization.ts
new file mode 100644
index 00000000..472a52c3
--- /dev/null
+++ b/apps/web/src/trpc/middleware/organization.ts
@@ -0,0 +1,49 @@
+import { db } from "@/db";
+import { members, organizations } from "@/db/schema";
+import { TRPCError } from "@trpc/server";
+import { and, eq } from "drizzle-orm";
+import { t } from "../init";
+
+export const isOrganizationMember = t.middleware(
+ async ({ ctx, next, input }) => {
+ if (!ctx.user) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You must be logged in",
+ });
+ }
+
+ const typedInput = input as { organizationId: string };
+
+ const result = await db
+ .select({
+ member: members,
+ })
+ .from(organizations)
+ .leftJoin(
+ members,
+ and(
+ eq(members.organizationId, typedInput.organizationId),
+ eq(members.userId, ctx.user.id),
+ ),
+ )
+ .where(eq(organizations.id, typedInput.organizationId))
+ .get();
+
+ if (!result) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Organization not found",
+ });
+ }
+
+ if (!result.member) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You are not a member of this organization",
+ });
+ }
+
+ return next();
+ },
+);
diff --git a/apps/web/src/trpc/middleware/project.ts b/apps/web/src/trpc/middleware/project.ts
new file mode 100644
index 00000000..0e228165
--- /dev/null
+++ b/apps/web/src/trpc/middleware/project.ts
@@ -0,0 +1,54 @@
+import { db } from "@/db";
+import { members, organizations, projects } from "@/db/schema";
+import { TRPCError } from "@trpc/server";
+import { and, eq } from "drizzle-orm";
+import { t } from "../init";
+
+export const isProjectMember = t.middleware(async ({ ctx, next, input }) => {
+ if (!ctx.user) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You must be logged in",
+ });
+ }
+
+ const typedInput = input as { slug: string; organizationId: string };
+
+ const result = await db
+ .select({
+ member: members,
+ project: projects,
+ })
+ .from(projects)
+ .innerJoin(organizations, eq(organizations.id, typedInput.organizationId))
+ .leftJoin(
+ members,
+ and(
+ eq(members.organizationId, typedInput.organizationId),
+ eq(members.userId, ctx.user.id),
+ ),
+ )
+ .where(
+ and(
+ eq(projects.slug, typedInput.slug),
+ eq(projects.organizationId, typedInput.organizationId),
+ ),
+ )
+ .get();
+
+ if (!result) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Project not found",
+ });
+ }
+
+ if (!result.member) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You don't have access to this project",
+ });
+ }
+
+ return next();
+});
diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts
index 82577c9d..ec47be75 100644
--- a/apps/web/src/trpc/routers/_app.ts
+++ b/apps/web/src/trpc/routers/_app.ts
@@ -1,9 +1,13 @@
import type { inferRouterOutputs } from "@trpc/server";
import { createTRPCRouter } from "../init";
import { organizationRouter } from "./organization";
+import { projectRouter } from "./project";
+import { userRouter } from "./user";
export const appRouter = createTRPCRouter({
organization: organizationRouter,
+ project: projectRouter,
+ user: userRouter,
});
// export type definition of API
diff --git a/apps/web/src/trpc/routers/organization.ts b/apps/web/src/trpc/routers/organization.ts
index 80f0e79f..e8e3a602 100644
--- a/apps/web/src/trpc/routers/organization.ts
+++ b/apps/web/src/trpc/routers/organization.ts
@@ -1,22 +1,24 @@
-import { db } from "@/db";
-import { getAllOrganizationsWithProjects } from "@/db/queries/select";
-import { members, organizations, projects } from "@/db/schema";
-import { createId } from "@paralleldrive/cuid2";
+import {
+ createOrganization,
+ deleteOrganization,
+ deleteOrganizationInvite,
+ getAllOrganizationsWithProjects,
+ getOrganization,
+ getOrganizationInvites,
+ getOrganizationMembers,
+ updateOrganization,
+} from "@/db/queries/organization";
import { TRPCError } from "@trpc/server";
-import { eq } from "drizzle-orm";
-import slugify from "slugify";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../init";
+import { isOrganizationMember } from "../middleware/organization";
export const organizationRouter = createTRPCRouter({
getById: protectedProcedure
- .input(z.object({ id: z.string() }))
+ .input(z.object({ organizationId: z.string() }))
+ .use(isOrganizationMember)
.query(async ({ input }) => {
- const org = await db
- .select()
- .from(organizations)
- .where(eq(organizations.id, input.id))
- .get();
+ const org = await getOrganization(input.organizationId);
if (!org) {
throw new TRPCError({
@@ -32,22 +34,31 @@ export const organizationRouter = createTRPCRouter({
return getAllOrganizationsWithProjects(ctx.user.id);
}),
+ getMembers: protectedProcedure
+ .input(z.object({ organizationId: z.string() }))
+ .use(isOrganizationMember)
+ .query(async ({ input }) => {
+ return getOrganizationMembers(input.organizationId);
+ }),
+
+ getInvites: protectedProcedure
+ .input(z.object({ organizationId: z.string() }))
+ .use(isOrganizationMember)
+ .query(async ({ input }) => {
+ return getOrganizationInvites(input.organizationId);
+ }),
+
create: protectedProcedure
.input(
z.object({
name: z.string().min(1),
- userId: z.string(),
}),
)
- .mutation(async ({ input }) => {
- const org = await db
- .insert(organizations)
- .values({
- name: input.name,
- slug: `${slugify(input.name, { lower: true })}-${createId().slice(0, 8)}`,
- })
- .returning()
- .get();
+ .mutation(async ({ input, ctx }) => {
+ const org = await createOrganization({
+ name: input.name,
+ userId: ctx.user.id,
+ });
if (!org) {
throw new TRPCError({
@@ -56,39 +67,24 @@ export const organizationRouter = createTRPCRouter({
});
}
- await db.insert(members).values({
- userId: input.userId,
- organizationId: org.id,
- role: "owner",
- });
-
- await db.insert(projects).values({
- name: "Default",
- organizationId: org.id,
- slug: "default",
- });
-
return org;
}),
update: protectedProcedure
.input(
z.object({
- id: z.string(),
+ organizationId: z.string(),
name: z.string().min(1),
logo: z.string().optional(),
}),
)
+ .use(isOrganizationMember)
.mutation(async ({ input }) => {
- const org = await db
- .update(organizations)
- .set({
- name: input.name,
- logo: input.logo,
- })
- .where(eq(organizations.id, input.id))
- .returning()
- .get();
+ const org = await updateOrganization({
+ id: input.organizationId,
+ name: input.name,
+ logo: input.logo,
+ });
if (!org) {
throw new TRPCError({
@@ -101,13 +97,10 @@ export const organizationRouter = createTRPCRouter({
}),
delete: protectedProcedure
- .input(z.object({ id: z.string() }))
+ .input(z.object({ organizationId: z.string() }))
+ .use(isOrganizationMember)
.mutation(async ({ input }) => {
- const org = await db
- .delete(organizations)
- .where(eq(organizations.id, input.id))
- .returning()
- .get();
+ const org = await deleteOrganization(input.organizationId);
if (!org) {
throw new TRPCError({
@@ -118,4 +111,25 @@ export const organizationRouter = createTRPCRouter({
return org;
}),
+
+ deleteInvite: protectedProcedure
+ .input(
+ z.object({
+ organizationId: z.string(),
+ inviteId: z.string(),
+ }),
+ )
+ .use(isOrganizationMember)
+ .mutation(async ({ input }) => {
+ const invite = await deleteOrganizationInvite(input.inviteId);
+
+ if (!invite) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to delete organization invite",
+ });
+ }
+
+ return invite;
+ }),
});
diff --git a/apps/web/src/trpc/routers/project.ts b/apps/web/src/trpc/routers/project.ts
new file mode 100644
index 00000000..53543993
--- /dev/null
+++ b/apps/web/src/trpc/routers/project.ts
@@ -0,0 +1,96 @@
+import {
+ createProject,
+ deleteProject,
+ getProjectBySlug,
+ updateProject,
+} from "@/db/queries/project";
+import { TRPCError } from "@trpc/server";
+import { z } from "zod";
+import { createTRPCRouter, protectedProcedure } from "../init";
+import { isProjectMember } from "../middleware/project";
+
+export const projectRouter = createTRPCRouter({
+ getBySlug: protectedProcedure
+ .input(
+ z.object({
+ slug: z.string(),
+ organizationId: z.string(),
+ }),
+ )
+ .use(isProjectMember)
+ .query(async ({ input }) => {
+ const project = await getProjectBySlug(input);
+
+ if (!project) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Project not found",
+ });
+ }
+
+ return project;
+ }),
+
+ create: protectedProcedure
+ .input(
+ z.object({
+ name: z.string().min(1),
+ organizationId: z.string(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const project = await createProject(input);
+
+ if (!project) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to create project",
+ });
+ }
+
+ return project;
+ }),
+
+ update: protectedProcedure
+ .input(
+ z.object({
+ slug: z.string(),
+ organizationId: z.string(),
+ name: z.string().min(1),
+ }),
+ )
+ .use(isProjectMember)
+ .mutation(async ({ input }) => {
+ const project = await updateProject(input);
+
+ if (!project) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to update project",
+ });
+ }
+
+ return project;
+ }),
+
+ delete: protectedProcedure
+ .input(
+ z.object({
+ slug: z.string(),
+ organizationId: z.string(),
+ }),
+ )
+ .use(isProjectMember)
+ .mutation(async ({ input }) => {
+ const project = await deleteProject(input);
+
+ if (!project) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to delete project",
+ });
+ }
+
+ return project;
+ }),
+});
diff --git a/apps/web/src/trpc/routers/user.ts b/apps/web/src/trpc/routers/user.ts
new file mode 100644
index 00000000..bee0714c
--- /dev/null
+++ b/apps/web/src/trpc/routers/user.ts
@@ -0,0 +1,19 @@
+import { getUserById, updateUser } from "@/db/queries/user";
+import { z } from "zod";
+import { createTRPCRouter, protectedProcedure } from "../init";
+
+export const userRouter = createTRPCRouter({
+ me: protectedProcedure.query(async ({ ctx }) => {
+ return getUserById({ id: ctx.user.id });
+ }),
+ update: protectedProcedure
+ .input(
+ z.object({
+ name: z.string().optional(),
+ email: z.string().email().optional(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ return updateUser({ id: ctx.user.id, ...input });
+ }),
+});
diff --git a/apps/web/src/trpc/server.ts b/apps/web/src/trpc/server.ts
index 8f91d8e0..460964af 100644
--- a/apps/web/src/trpc/server.ts
+++ b/apps/web/src/trpc/server.ts
@@ -1,4 +1,4 @@
-import "server-only"; // <-- ensure this file cannot be imported from the client
+import "server-only";
import { createHydrationHelpers } from "@trpc/react-query/rsc";
import { cache } from "react";
@@ -6,10 +6,10 @@ import { createCallerFactory, createTRPCContext } from "./init";
import { makeQueryClient } from "./query-client";
import { appRouter } from "./routers/_app";
-// IMPORTANT: Create a stable getter for the query client that
-// will return the same client during the same request.
export const getQueryClient = cache(makeQueryClient);
+
const caller = createCallerFactory(appRouter)(createTRPCContext);
+
export const { trpc, HydrateClient } = createHydrationHelpers(
caller,
getQueryClient,
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
index 7c75904c..bf890716 100644
--- a/apps/web/tailwind.config.ts
+++ b/apps/web/tailwind.config.ts
@@ -8,80 +8,80 @@ export default {
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
- fontFamily: {
- mono: 'var(--font-geist-mono)'
- },
- extend: {
- keyframes: {
- 'caret-blink': {
- '0%,70%,100%': {
- opacity: '1'
- },
- '20%,50%': {
- opacity: '0'
- }
- }
- },
- animation: {
- 'caret-blink': 'caret-blink 1.25s ease-out infinite'
- },
- colors: {
- background: 'hsl(var(--background))',
- foreground: 'hsl(var(--foreground))',
- card: {
- DEFAULT: 'hsl(var(--card))',
- foreground: 'hsl(var(--card-foreground))'
- },
- popover: {
- DEFAULT: 'hsl(var(--popover))',
- foreground: 'hsl(var(--popover-foreground))'
- },
- primary: {
- DEFAULT: 'hsl(var(--primary))',
- foreground: 'hsl(var(--primary-foreground))'
- },
- secondary: {
- DEFAULT: 'hsl(var(--secondary))',
- foreground: 'hsl(var(--secondary-foreground))'
- },
- muted: {
- DEFAULT: 'hsl(var(--muted))',
- foreground: 'hsl(var(--muted-foreground))'
- },
- accent: {
- DEFAULT: 'hsl(var(--accent))',
- foreground: 'hsl(var(--accent-foreground))'
- },
- destructive: {
- DEFAULT: 'hsl(var(--destructive))',
- foreground: 'hsl(var(--destructive-foreground))'
- },
- border: 'hsl(var(--border))',
- input: 'hsl(var(--input))',
- ring: 'hsl(var(--ring))',
- chart: {
- '1': 'hsl(var(--chart-1))',
- '2': 'hsl(var(--chart-2))',
- '3': 'hsl(var(--chart-3))',
- '4': 'hsl(var(--chart-4))',
- '5': 'hsl(var(--chart-5))'
- },
- sidebar: {
- DEFAULT: 'hsl(var(--sidebar-background))',
- foreground: 'hsl(var(--sidebar-foreground))',
- primary: 'hsl(var(--sidebar-primary))',
- 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
- accent: 'hsl(var(--sidebar-accent))',
- 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
- border: 'hsl(var(--sidebar-border))',
- ring: 'hsl(var(--sidebar-ring))'
- }
- },
- borderRadius: {
- lg: 'var(--radius)',
- md: 'calc(var(--radius) - 2px)',
- sm: 'calc(var(--radius) - 4px)'
- }
- }
+ fontFamily: {
+ mono: "var(--font-geist-mono)",
+ },
+ extend: {
+ keyframes: {
+ "caret-blink": {
+ "0%,70%,100%": {
+ opacity: "1",
+ },
+ "20%,50%": {
+ opacity: "0",
+ },
+ },
+ },
+ animation: {
+ "caret-blink": "caret-blink 1.25s ease-out infinite",
+ },
+ colors: {
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ chart: {
+ "1": "hsl(var(--chart-1))",
+ "2": "hsl(var(--chart-2))",
+ "3": "hsl(var(--chart-3))",
+ "4": "hsl(var(--chart-4))",
+ "5": "hsl(var(--chart-5))",
+ },
+ sidebar: {
+ DEFAULT: "hsl(var(--sidebar-background))",
+ foreground: "hsl(var(--sidebar-foreground))",
+ primary: "hsl(var(--sidebar-primary))",
+ "primary-foreground": "hsl(var(--sidebar-primary-foreground))",
+ accent: "hsl(var(--sidebar-accent))",
+ "accent-foreground": "hsl(var(--sidebar-accent-foreground))",
+ border: "hsl(var(--sidebar-border))",
+ ring: "hsl(var(--sidebar-ring))",
+ },
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ },
},
} satisfies Config;
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index b47edf5b..5f60a542 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -20,9 +20,8 @@
],
"baseUrl": ".",
"paths": {
- "@/*": ["src/*", "../api/src/*"]
+ "@/*": ["src/*"]
},
- "types": ["@cloudflare/workers-types"]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", ".next"]
diff --git a/bun.lockb b/bun.lockb
index 7ae8c6ef..565b4a99 100755
Binary files a/bun.lockb and b/bun.lockb differ