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 ( -
+
- - {t("dangerZone.dialog.title")} + + + {t("dangerZone.dialog.title")} + {t("dangerZone.dialog.description")} + setDeleteText(e.target.value)} placeholder={t("dangerZone.dialog.placeholder")} /> - +
+ + +
diff --git a/apps/web/src/components/modals/create-project.tsx b/apps/web/src/components/modals/create-project.tsx new file mode 100644 index 00000000..287b7fa6 --- /dev/null +++ b/apps/web/src/components/modals/create-project.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useCreateProjectModal } from "@/hooks/use-create-project-modal"; +import { useI18n } from "@/locales/client"; +import { trpc } from "@/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const formSchema = z.object({ + name: z.string().min(1, "Project name is required"), +}); + +export function CreateProjectModal() { + const t = useI18n(); + const { open, setOpen } = useCreateProjectModal(); + const params = useParams(); + const organizationId = params.organization as string; + const utils = trpc.useUtils(); + const router = useRouter(); + + const createProject = trpc.project.create.useMutation({ + onSuccess: (project) => { + utils.project.invalidate(); + utils.organization.invalidate(); + + router.replace(`/${organizationId}/${project.slug}`); + }, + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + }, + }); + + useEffect(() => { + if (open) { + form.reset(); + } + }, [open]); + + async function onSubmit(values: z.infer) { + try { + await createProject.mutateAsync({ + name: values.name, + organizationId, + }); + + form.reset(); + } catch (error) { + console.error("Failed to create project:", error); + } + } + + return ( + + + + {t("createProject.createProjectTitle")} + +

+ {t("createProject.createProjectDescription")} +

+
+ + ( + + Project Name + + + + + )} + /> +
+ + +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/modals/create-team.tsx b/apps/web/src/components/modals/create-team.tsx new file mode 100644 index 00000000..fefdb46e --- /dev/null +++ b/apps/web/src/components/modals/create-team.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useCreateTeamModal } from "@/hooks/use-create-team-modal"; +import { useI18n } from "@/locales/client"; +import { trpc } from "@/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const formSchema = z.object({ + name: z.string().min(1, "Team name is required"), +}); + +export function CreateTeamModal() { + const t = useI18n(); + const { open, setOpen } = useCreateTeamModal(); + const router = useRouter(); + const utils = trpc.useUtils(); + + const createTeam = trpc.organization.create.useMutation({ + onSuccess: (team) => { + utils.organization.invalidate(); + router.replace(`/${team.id}/default`); + }, + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + }, + }); + + useEffect(() => { + if (open) { + form.reset(); + } + }, [open]); + + async function onSubmit(values: z.infer) { + try { + await createTeam.mutateAsync({ + name: values.name, + }); + form.reset(); + } catch (error) { + console.error("Failed to create team:", error); + } + } + + return ( + + + + {t("teamSelector.createTeamTitle")} + +

+ {t("createTeam.createTeamDescription")} +

+
+ + ( + + {t("createTeam.teamName")} + + + + + )} + /> +
+ + +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/modals/index.tsx b/apps/web/src/components/modals/index.tsx new file mode 100644 index 00000000..5af94388 --- /dev/null +++ b/apps/web/src/components/modals/index.tsx @@ -0,0 +1,13 @@ +import { CreateProjectModal } from "./create-project"; +import { CreateTeamModal } from "./create-team"; +import { InviteModal } from "./invite"; + +export function GlobalModals() { + return ( + <> + + + + + ); +} diff --git a/apps/web/src/components/modals/invite.tsx b/apps/web/src/components/modals/invite.tsx new file mode 100644 index 00000000..2c14bdc1 --- /dev/null +++ b/apps/web/src/components/modals/invite.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { useInviteModal } from "@/hooks/use-invite-modal"; +import { authClient } from "@/lib/auth/client"; +import { useI18n } from "@/locales/client"; +import { trpc } from "@/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +export function InviteModal() { + const t = useI18n(); + const { open, setOpen } = useInviteModal(); + const utils = trpc.useUtils(); + + const form = useForm<{ email: string }>({ + resolver: zodResolver( + z.object({ + email: z.string().email(t("invite.validation.invalidEmail")), + }), + ), + defaultValues: { + email: "", + }, + }); + + useEffect(() => { + if (open) { + form.reset(); + } + }, [open]); + + async function onSubmit(values: { email: string }) { + try { + await authClient.organization.inviteMember({ + email: values.email, + role: "member", + }); + + utils.organization.getInvites.invalidate(); + form.reset(); + setOpen(false); + toast.success(t("invite.success.title"), { + description: t("invite.success.description", { email: values.email }), + }); + } catch (error) { + console.error("Failed to invite member:", error); + toast.error(t("invite.error.title"), { + description: t("invite.error.description"), + }); + } + } + + return ( + + + + {t("invite.inviteMember")} + +

+ {t("invite.inviteDescription")} +

+
+ + ( + + {t("invite.emailLabel")} + + + + + + )} + /> +
+ + +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/settings-card.tsx b/apps/web/src/components/settings-card.tsx index 8405697d..0c62094e 100644 --- a/apps/web/src/components/settings-card.tsx +++ b/apps/web/src/components/settings-card.tsx @@ -1,6 +1,7 @@ "use client"; import { CopyInput } from "@/components/copy-input"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { @@ -10,9 +11,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { useI18n } from "@/locales/client"; +import { useState } from "react"; import { toast } from "sonner"; export function SettingsTitle({ title }: { title: string }) { @@ -31,22 +35,68 @@ export function SettingsCard({ type = "input", value, onChange, + onSave, checked, onCheckedChange, options, placeholder, + isLoading, + validate, }: { title: string; description: string; type?: "input" | "textarea" | "switch" | "select" | "copy-input"; value?: string; onChange?: (value: string) => void; + onSave?: (value: string) => void; checked?: boolean; onCheckedChange?: (checked: boolean) => void; options?: { label: string; value: string }[]; placeholder?: string; + isLoading?: boolean; + validate?: "email" | "url" | "number" | "password" | "text"; }) { const t = useI18n(); + const [isSaving, setIsSaving] = useState(false); + const [inputValue, setInputValue] = useState(value ?? ""); + + const handleSave = async () => { + try { + setIsSaving(true); + await onSave?.(inputValue); + toast.success(t("settings.saved"), { + description: t("settings.savedDescription"), + }); + } catch (error) { + toast.error(t("settings.error"), { + description: t("settings.errorDescription"), + }); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) { + return ( +
+ + +
+
+ + +
+ {type === "switch" && } + {type === "select" && } +
+
+ + + +
+
+ ); + } return (
@@ -64,7 +114,7 @@ export function SettingsCard({ checked={checked} onCheckedChange={() => { onCheckedChange?.(!!checked); - toast(t("settings.saved"), { + toast.success(t("settings.saved"), { description: t("settings.savedDescription"), }); }} @@ -91,11 +141,29 @@ export function SettingsCard({ {type === "input" && ( - onChange?.(e.target.value)} - placeholder={placeholder} - /> +
{ + e.preventDefault(); + handleSave(); + }} + > + setInputValue?.(e.target.value)} + placeholder={placeholder} + type={validate} + required + /> + +
)} {type === "textarea" && (