From af2d9a355cf0e05d30a26e104b4b39dbce9c7524 Mon Sep 17 00:00:00 2001 From: RISHII <93994298+RISHII7@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:36:19 +0530 Subject: [PATCH] feat(web): add Clerk organizations with module-based auth architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces multi-tenant organization support via Clerk Organizations and refactors the entire authentication layer into a dedicated feature module under apps/web/modules/auth/, separating routing from domain logic. ## Auth module architecture Extracted all auth UI into modules/auth/ui/ following a views/layouts/components structure: - AuthLayout — full-screen centered wrapper for all auth pages - SignInView — (hash routing prevents full-page navigations on Clerk multi-step flows) - SignUpView — - OrgSelectionView — with post-selection redirect to / - AuthGuard — Convex Authenticated/Unauthenticated/AuthLoading guard; shows SignInView inline when unauthenticated (no redirect) - OrganizationGuard — useOrganization() guard; renders OrgSelectionView inside AuthLayout when no active org is present ## Guard composition at dashboard layout level app/(dashboard)/layout.tsx wraps all dashboard routes with: {children} Both conditions (Convex session + Clerk org) must be satisfied before any dashboard page renders. No per-page checks required. ## Organization redirect in middleware proxy.ts extended with org enforcement: - isOrgFreeRoute matcher covers /sign-in, /sign-up, /org-selection - Authenticated users without an active orgId are redirected to /org-selection?redirectUrl= unless already on an org-free route ## Auth pages refactored to module views - app/(auth)/layout.tsx → delegates to - app/(auth)/sign-in/page.tsx → delegates to - app/(auth)/sign-up/page.tsx → delegates to - app/(auth)/org-selection/page.tsx (new) → delegates to ## Dashboard page app/(dashboard)/page.tsx replaces the deleted app/page.tsx as the application root. Shows UserButton, OrganizationSwitcher (hidePersonal), and the Convex users table demo (useQuery + useMutation). ## Docs - CHANGELOG.md updated with v0.4.0 section; compare link added - README.md: Organizations added to Features; Architecture and Project Structure sections updated to reflect modules/ and route groups --- CHANGELOG.md | 96 ++++++++++++++++++- README.md | 20 ++-- apps/web/app/(auth)/layout.tsx | 8 +- .../[[...org-selection]]/page.tsx | 6 ++ .../(auth)/sign-in/[[...sign-in]]/page.tsx | 4 +- .../(auth)/sign-up/[[...sign-up]]/page.tsx | 4 +- apps/web/app/(dashboard)/layout.tsx | 12 +++ apps/web/app/(dashboard)/page.tsx | 24 +++++ apps/web/app/page.tsx | 34 ------- .../auth/ui/components/auth-guard/index.tsx | 25 +++++ .../components/organization-guard/index.tsx | 23 +++++ .../auth/ui/layouts/auth-layout/index.tsx | 7 ++ .../ui/views/org-selection-view/index.tsx | 12 +++ .../auth/ui/views/sign-in-view/index.tsx | 5 + .../auth/ui/views/sign-up-view/index.tsx | 5 + apps/web/proxy.ts | 18 ++++ 16 files changed, 253 insertions(+), 50 deletions(-) create mode 100644 apps/web/app/(auth)/org-selection/[[...org-selection]]/page.tsx create mode 100644 apps/web/app/(dashboard)/layout.tsx create mode 100644 apps/web/app/(dashboard)/page.tsx delete mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/modules/auth/ui/components/auth-guard/index.tsx create mode 100644 apps/web/modules/auth/ui/components/organization-guard/index.tsx create mode 100644 apps/web/modules/auth/ui/layouts/auth-layout/index.tsx create mode 100644 apps/web/modules/auth/ui/views/org-selection-view/index.tsx create mode 100644 apps/web/modules/auth/ui/views/sign-in-view/index.tsx create mode 100644 apps/web/modules/auth/ui/views/sign-up-view/index.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 2103f17..27f426c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,99 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.4.0] - 2026-07-01 + +### Overview + +This release adds **Clerk Organizations** support and refactors the auth layer into a +module-based architecture under `apps/web/modules/`. Authentication and organization +membership are now enforced at the layout level via reusable guard components, and all +auth views are isolated into purpose-built view modules rather than living directly in +the app directory. + +--- + +### Added + +#### Clerk Organizations + +- **`app/(auth)/org-selection/[[...org-selection]]/page.tsx`** — catch-all page for + the Clerk organization selection flow; renders `OrgSelectionView` +- **`modules/auth/ui/views/org-selection-view/index.tsx`** — `OrgSelectionView` wrapping + Clerk's `` with `hidePersonal`, `skipInvitationScreen`, and + post-selection redirect to `/` +- **`modules/auth/ui/components/organization-guard/index.tsx`** — `OrganizationGuard` + client component; uses `useOrganization()` from `@clerk/nextjs` to detect whether the + current user has an active organization. Renders `OrgSelectionView` within `AuthLayout` + when no organization is selected, otherwise renders children + +#### Module-based auth architecture — `apps/web/modules/` + +- **`modules/auth/ui/layouts/auth-layout/index.tsx`** — `AuthLayout` component; full-screen + centered wrapper for all auth pages +- **`modules/auth/ui/views/sign-in-view/index.tsx`** — `SignInView` rendering + `` (hash routing prevents full-page navigations on multi-step flows) +- **`modules/auth/ui/views/sign-up-view/index.tsx`** — `SignUpView` rendering + `` +- **`modules/auth/ui/components/auth-guard/index.tsx`** — `AuthGuard` client component; + uses Convex ``, ``, and `` guards to + conditionally render children, a loading state, or the `SignInView` without redirecting + +#### Dashboard layout with guard composition + +- **`app/(dashboard)/layout.tsx`** — layout for all dashboard routes; composes + `` (Convex session check) wrapping `` (Clerk org check) + so both conditions must be satisfied before any dashboard page renders +- **`app/(dashboard)/page.tsx`** — dashboard home; shows ``, + ``, Convex `useQuery`/`useMutation` for the users + table, and an Add button + +--- + +### Changed + +#### Auth pages refactored to module views + +- **`app/(auth)/layout.tsx`** — replaced inline centering div with `` from + modules, centralising auth page layout in one place +- **`app/(auth)/sign-in/[[...sign-in]]/page.tsx`** — replaced inline `` with + `` from modules +- **`app/(auth)/sign-up/[[...sign-up]]/page.tsx`** — replaced inline `` with + `` from modules + +#### Middleware — org redirect logic + +- **`proxy.ts`** — extended `clerkMiddleware` with organization enforcement: + - Added `isOrgFreeRoute` matcher covering `/sign-in(.*)`, `/sign-up(.*)`, + `/org-selection(.*)` + - After auth protection, authenticated users without an active `orgId` are redirected + to `/org-selection?redirectUrl=` unless they are already on an org-free route + +#### Dashboard page replaces old app root + +- **`app/page.tsx`** — deleted; the application root is now `app/(dashboard)/page.tsx` + inside the `(dashboard)` route group. The `/` URL maps to the dashboard after auth + and org guards pass. + +--- + +### Technical Decisions + +- **Module-based architecture over flat app directory** — co-locating views, layouts, and + guard components under `modules/auth/ui/` makes each auth concern independently testable + and keeps the app directory as a thin routing layer. +- **`routing="hash"` on Clerk components** — prevents Clerk's multi-step sign-in/sign-up + flows from triggering full Next.js navigations; state is tracked in the URL hash instead. +- **Guard composition at layout level** — `AuthGuard` + `OrganizationGuard` in + `(dashboard)/layout.tsx` means every dashboard route automatically inherits both + protection layers without per-page checks. +- **Middleware redirect vs. guard-only** — the `proxy.ts` redirect handles the server-side + case (direct URL navigation without a Clerk session context in React), while + `OrganizationGuard` handles the client-side case (org changed or deselected after page + load). + +--- + ## [0.3.0] - 2026-06-30 ### Overview @@ -337,7 +430,8 @@ Initial release of **Echo** — an enterprise-grade full-stack monorepo platform --- -[Unreleased]: https://github.com/RISHII7/echo/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/RISHII7/echo/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/RISHII7/echo/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/RISHII7/echo/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/RISHII7/echo/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/RISHII7/echo/compare/v0.1.0...v0.1.1 diff --git a/README.md b/README.md index 00f7308..ccb912d 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Echo is a production-ready, full-stack monorepo platform engineered for enterpri - **Monorepo Architecture** — Turborepo with remote caching, task pipelines, and workspace dependency graph - **Authentication** — Clerk with hosted sign-in/sign-up UI, session management, and middleware-level route protection +- **Organizations** — Clerk multi-tenant organizations with org-selection flow, guard-based enforcement, and `OrganizationSwitcher` - **Real-Time Backend** — Convex reactive database with live queries and server mutations, bridged to Clerk sessions - **Type Safety** — End-to-end TypeScript with strict mode and generated API types across all packages - **Design System** — Shared UI component library built on shadcn/ui and Radix primitives @@ -75,17 +76,19 @@ Echo is a production-ready, full-stack monorepo platform engineered for enterpri ``` echo/ ├── apps/ -│ ├── web/ # Primary Next.js application -│ └── widget/ # Embeddable widget application +│ ├── web/ # Primary Next.js application +│ │ └── modules/ +│ │ └── auth/ # Auth feature module (views, layouts, guards) +│ └── widget/ # Embeddable widget application ├── packages/ -│ ├── backend/ # Convex real-time backend (schema + server functions) -│ ├── ui/ # Shared component library -│ ├── math/ # Shared utilities +│ ├── backend/ # Convex real-time backend (schema + server functions) +│ ├── ui/ # Shared component library +│ ├── math/ # Shared utilities │ ├── eslint-config/ # Shared ESLint configuration │ └── typescript-config/ # Shared TypeScript configuration ``` -The monorepo uses a **workspace dependency graph** where apps consume packages, and packages can depend on other packages. Turborepo ensures tasks run in topological order with caching at every layer. The `@workspace/backend` package provides a shared Convex client that both apps import for real-time data access. +The monorepo uses a **workspace dependency graph** where apps consume packages, and packages can depend on other packages. Turborepo ensures tasks run in topological order with caching at every layer. The `@workspace/backend` package provides a shared Convex client that both apps import for real-time data access. Feature modules under `apps/web/modules/` isolate domain logic (views, layouts, guards) from the Next.js app directory routing layer. --- @@ -228,6 +231,11 @@ echo/ │ └── PULL_REQUEST_TEMPLATE.md ├── apps/ │ ├── web/ # Next.js web application +│ │ ├── app/ +│ │ │ ├── (auth)/ # Auth route group (sign-in, sign-up, org-selection) +│ │ │ └── (dashboard)/ # Dashboard route group (auth + org guarded) +│ │ └── modules/ +│ │ └── auth/ui/ # Auth views, layouts, and guard components │ └── widget/ # Embeddable widget ├── packages/ │ ├── backend/ # Convex real-time backend diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx index 5e74a56..a04bead 100644 --- a/apps/web/app/(auth)/layout.tsx +++ b/apps/web/app/(auth)/layout.tsx @@ -1,9 +1,7 @@ +import { AuthLayout } from "@/modules/auth/ui/layouts/auth-layout" + const Layout = ({ children }: { children: React.ReactNode }) => { - return ( -
- {children} -
- ) + return {children} } export default Layout diff --git a/apps/web/app/(auth)/org-selection/[[...org-selection]]/page.tsx b/apps/web/app/(auth)/org-selection/[[...org-selection]]/page.tsx new file mode 100644 index 0000000..4a33991 --- /dev/null +++ b/apps/web/app/(auth)/org-selection/[[...org-selection]]/page.tsx @@ -0,0 +1,6 @@ +import { OrgSelectionView } from "@/modules/auth/ui/views/org-selection-view" + +const Page = () => { + return +} +export default Page diff --git a/apps/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/apps/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx index 4602981..5a829af 100644 --- a/apps/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx +++ b/apps/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx @@ -1,7 +1,7 @@ -import { SignIn } from "@clerk/nextjs" +import { SignInView } from "@/modules/auth/ui/views/sign-in-view" const Page = () => { - return + return } export default Page diff --git a/apps/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/apps/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx index 2c0d33c..b928853 100644 --- a/apps/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx +++ b/apps/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx @@ -1,7 +1,7 @@ -import { SignUp } from "@clerk/nextjs" +import { SignUpView } from "@/modules/auth/ui/views/sign-up-view" const Page = () => { - return + return } export default Page diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..d4c4dc0 --- /dev/null +++ b/apps/web/app/(dashboard)/layout.tsx @@ -0,0 +1,12 @@ +import { AuthGuard } from "@/modules/auth/ui/components/auth-guard" +import { OrganizationGuard } from "@/modules/auth/ui/components/organization-guard" + +const Layout = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ) +} + +export default Layout diff --git a/apps/web/app/(dashboard)/page.tsx b/apps/web/app/(dashboard)/page.tsx new file mode 100644 index 0000000..ff5470b --- /dev/null +++ b/apps/web/app/(dashboard)/page.tsx @@ -0,0 +1,24 @@ +"use client" + +import { useMutation, useQuery } from "convex/react" +import { OrganizationSwitcher, UserButton } from "@clerk/nextjs" + +import { api } from "@workspace/backend/_generated/api" + +import { Button } from "@workspace/ui/components/button" + +export default function Page() { + const users = useQuery(api.users.getMany) + const addUser = useMutation(api.users.add) + return ( +
+

apps/web

+ + + +
+ {JSON.stringify(users, null, 2)} +
+
+ ) +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx deleted file mode 100644 index 1d6106d..0000000 --- a/apps/web/app/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -"use client" - -import { - useMutation, - useQuery, - Authenticated, - Unauthenticated, -} from "convex/react" -import { api } from "@workspace/backend/_generated/api" -import { Button } from "@workspace/ui/components/button" -import { SignInButton, UserButton } from "@clerk/nextjs" - -export default function Page() { - const users = useQuery(api.users.getMany) - const addUser = useMutation(api.users.add) - return ( - <> - -
-

apps/web

- - -
- {JSON.stringify(users, null, 2)} -
-
-
- -

Must be signed In

- Sign In! -
- - ) -} diff --git a/apps/web/modules/auth/ui/components/auth-guard/index.tsx b/apps/web/modules/auth/ui/components/auth-guard/index.tsx new file mode 100644 index 0000000..683ca31 --- /dev/null +++ b/apps/web/modules/auth/ui/components/auth-guard/index.tsx @@ -0,0 +1,25 @@ +"use client" + +import { Authenticated, AuthLoading, Unauthenticated } from "convex/react" + +import { SignInView } from "@/modules/auth/ui/views/sign-in-view" + +import { AuthLayout } from "@/modules/auth/ui/layouts/auth-layout" + +export const AuthGuard = ({ children }: { children: React.ReactNode }) => { + return ( + <> + + +

Loading

+
+
+ {children} + + + + + + + ) +} diff --git a/apps/web/modules/auth/ui/components/organization-guard/index.tsx b/apps/web/modules/auth/ui/components/organization-guard/index.tsx new file mode 100644 index 0000000..42a56b4 --- /dev/null +++ b/apps/web/modules/auth/ui/components/organization-guard/index.tsx @@ -0,0 +1,23 @@ +"use client" + +import { useOrganization } from "@clerk/nextjs" + +import { AuthLayout } from "@/modules/auth/ui/layouts/auth-layout" +import { OrgSelectionView } from "@/modules/auth/ui/views/org-selection-view" + +export const OrganizationGuard = ({ + children, +}: { + children: React.ReactNode +}) => { + const { organization } = useOrganization() + + if (!organization) { + return ( + + + + ) + } + return
{children}
+} diff --git a/apps/web/modules/auth/ui/layouts/auth-layout/index.tsx b/apps/web/modules/auth/ui/layouts/auth-layout/index.tsx new file mode 100644 index 0000000..fea21c9 --- /dev/null +++ b/apps/web/modules/auth/ui/layouts/auth-layout/index.tsx @@ -0,0 +1,7 @@ +export const AuthLayout = ({ children }: { children: React.ReactNode }) => { + return ( +
+ {children} +
+ ) +} diff --git a/apps/web/modules/auth/ui/views/org-selection-view/index.tsx b/apps/web/modules/auth/ui/views/org-selection-view/index.tsx new file mode 100644 index 0000000..7672f34 --- /dev/null +++ b/apps/web/modules/auth/ui/views/org-selection-view/index.tsx @@ -0,0 +1,12 @@ +import { OrganizationList } from "@clerk/nextjs" + +export const OrgSelectionView = () => { + return ( + + ) +} diff --git a/apps/web/modules/auth/ui/views/sign-in-view/index.tsx b/apps/web/modules/auth/ui/views/sign-in-view/index.tsx new file mode 100644 index 0000000..f37f92a --- /dev/null +++ b/apps/web/modules/auth/ui/views/sign-in-view/index.tsx @@ -0,0 +1,5 @@ +import { SignIn } from "@clerk/nextjs" + +export const SignInView = () => { + return +} diff --git a/apps/web/modules/auth/ui/views/sign-up-view/index.tsx b/apps/web/modules/auth/ui/views/sign-up-view/index.tsx new file mode 100644 index 0000000..cc23a21 --- /dev/null +++ b/apps/web/modules/auth/ui/views/sign-up-view/index.tsx @@ -0,0 +1,5 @@ +import { SignUp } from "@clerk/nextjs" + +export const SignUpView = () => { + return +} diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index c27dc35..acacdda 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -1,11 +1,29 @@ +import { NextResponse } from "next/server" import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server" const isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]) +const isOrgFreeRoute = createRouteMatcher([ + "/sign-in(.*)", + "/sign-up(.*)", + "/org-selection(.*)", +]) + export default clerkMiddleware(async (auth, req) => { + const { userId, orgId } = await auth() if (!isPublicRoute(req)) { await auth.protect() } + if (userId && !orgId && !isOrgFreeRoute(req)) { + const searchParams = new URLSearchParams({ redirectUrl: req.url }) + + const orgSelection = new URL( + `/org-selection?${searchParams.toString()}`, + req.url + ) + + return NextResponse.redirect(orgSelection) + } }) export const config = {