Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 95 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,99 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [0.4.0] - 2026-07-01

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major

Version mismatch between PR objectives and changelog.

The PR objectives describe this as a post-release synchronization for v0.3.0, but the changelog introduces a v0.4.0 release section. Please confirm the intended version — this discrepancy could cause confusion in release automation and tagging.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 14, The changelog version heading does not match the
PR’s stated post-release sync target, so update the release section in
CHANGELOG.md to use the intended version consistently. Verify whether this
change should be recorded under v0.3.0 or v0.4.0, then align the top-level
changelog entry and any related release metadata with that same version to avoid
release/tagging confusion.


### 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 `<OrganizationList>` 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
`<SignIn routing="hash" />` (hash routing prevents full-page navigations on multi-step flows)
- **`modules/auth/ui/views/sign-up-view/index.tsx`** — `SignUpView` rendering
`<SignUp routing="hash" />`
- **`modules/auth/ui/components/auth-guard/index.tsx`** — `AuthGuard` client component;
uses Convex `<Authenticated>`, `<Unauthenticated>`, and `<AuthLoading>` 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
`<AuthGuard>` (Convex session check) wrapping `<OrganizationGuard>` (Clerk org check)
so both conditions must be satisfied before any dashboard page renders
- **`app/(dashboard)/page.tsx`** — dashboard home; shows `<UserButton />`,
`<OrganizationSwitcher hidePersonal />`, 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 `<AuthLayout>` from
modules, centralising auth page layout in one place
- **`app/(auth)/sign-in/[[...sign-in]]/page.tsx`** — replaced inline `<SignIn />` with
`<SignInView />` from modules
- **`app/(auth)/sign-up/[[...sign-up]]/page.tsx`** — replaced inline `<SignUp />` with
`<SignUpView />` 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=<original>` 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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

---

Expand Down Expand Up @@ -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
Expand Down
8 changes: 3 additions & 5 deletions apps/web/app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { AuthLayout } from "@/modules/auth/ui/layouts/auth-layout"

const Layout = ({ children }: { children: React.ReactNode }) => {
return (
<div className="flex h-full min-h-screen min-w-screen flex-col items-center justify-center">
{children}
</div>
)
return <AuthLayout>{children}</AuthLayout>
}

export default Layout
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { OrgSelectionView } from "@/modules/auth/ui/views/org-selection-view"

const Page = () => {
return <OrgSelectionView />
}
export default Page
4 changes: 2 additions & 2 deletions apps/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SignIn } from "@clerk/nextjs"
import { SignInView } from "@/modules/auth/ui/views/sign-in-view"

const Page = () => {
return <SignIn />
return <SignInView />
}

export default Page
4 changes: 2 additions & 2 deletions apps/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SignUp } from "@clerk/nextjs"
import { SignUpView } from "@/modules/auth/ui/views/sign-up-view"

const Page = () => {
return <SignUp />
return <SignUpView />
}

export default Page
12 changes: 12 additions & 0 deletions apps/web/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AuthGuard>
<OrganizationGuard>{children}</OrganizationGuard>
</AuthGuard>
)
}

export default Layout
24 changes: 24 additions & 0 deletions apps/web/app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-svh flex-col items-center justify-center">
<p> apps/web</p>
<UserButton />
<OrganizationSwitcher hidePersonal />
<Button onClick={() => addUser()}>Add</Button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Gate the add mutation behind an in-flight state.

Line 18 fires api.users.add on every click and never disables the button, so double-clicks can enqueue multiple writes before the first mutation settles. That is risky on an add path.

♻️ Proposed fix
+import { useState } from "react"
 import { useMutation, useQuery } from "convex/react"
 import { OrganizationSwitcher, UserButton } from "`@clerk/nextjs`"
@@
 export default function Page() {
   const users = useQuery(api.users.getMany)
   const addUser = useMutation(api.users.add)
+  const [isAdding, setIsAdding] = useState(false)
+
+  const handleAddUser = async () => {
+    if (isAdding) return
+    setIsAdding(true)
+    try {
+      await addUser()
+    } finally {
+      setIsAdding(false)
+    }
+  }
+
   return (
@@
-      <Button onClick={() => addUser()}>Add</Button>
+      <Button disabled={isAdding} onClick={handleAddUser}>
+        Add
+      </Button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Button onClick={() => addUser()}>Add</Button>
<Button disabled={isAdding} onClick={handleAddUser}>
Add
</Button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/`(dashboard)/page.tsx at line 18, The Add button currently
triggers addUser() without any in-flight protection, so repeated clicks can
submit duplicate api.users.add mutations. Update the page component’s button
handling in the dashboard page so the button is disabled while the add mutation
is pending, and make addUser() short-circuit or otherwise respect that pending
state. Use the existing addUser handler and the Button component to gate clicks
until the mutation settles.

<div className="mx-auto w-full max-w-sm">
{JSON.stringify(users, null, 2)}
</div>
</div>
)
}
34 changes: 0 additions & 34 deletions apps/web/app/page.tsx

This file was deleted.

25 changes: 25 additions & 0 deletions apps/web/modules/auth/ui/components/auth-guard/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<AuthLoading>
<AuthLayout>
<p>Loading</p>
</AuthLayout>
</AuthLoading>
<Authenticated>{children}</Authenticated>
<Unauthenticated>
<AuthLayout>
<SignInView />
</AuthLayout>
</Unauthenticated>
</>
)
}
Original file line number Diff line number Diff line change
@@ -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 (
<AuthLayout>
<OrgSelectionView />
</AuthLayout>
)
}
return <div>{children}</div>
}
7 changes: 7 additions & 0 deletions apps/web/modules/auth/ui/layouts/auth-layout/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const AuthLayout = ({ children }: { children: React.ReactNode }) => {
return (
<div className="flex h-full min-h-screen min-w-screen flex-col items-center justify-center">
{children}
</div>
)
}
12 changes: 12 additions & 0 deletions apps/web/modules/auth/ui/views/org-selection-view/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { OrganizationList } from "@clerk/nextjs"

export const OrgSelectionView = () => {
return (
<OrganizationList
afterCreateOrganizationUrl="/"
afterSelectOrganizationUrl="/"
hidePersonal
skipInvitationScreen
/>
)
}
5 changes: 5 additions & 0 deletions apps/web/modules/auth/ui/views/sign-in-view/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SignIn } from "@clerk/nextjs"

export const SignInView = () => {
return <SignIn routing="hash" />
}
5 changes: 5 additions & 0 deletions apps/web/modules/auth/ui/views/sign-up-view/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SignUp } from "@clerk/nextjs"

export const SignUpView = () => {
return <SignUp routing="hash" />
}
18 changes: 18 additions & 0 deletions apps/web/proxy.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
Loading