From 7ec2ede7ca5af561bc7e5b706015c5d52ee412dc Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 2 Jun 2026 00:25:48 -0500 Subject: [PATCH] feat(web): land authed users on their oldest workspace home by slug Path-A task 4/5. The root landing logic in apps/web/app/page.tsx was redirecting to `/${workspaceId}` (UUID, ugly) and using no ORDER BY (so two sessions could land on different workspaces). It also looped zero-workspace users through `/sign-in`. Changes: * Inner-join workspaceMembers with workspaces to fetch the slug, not just the id. Order by membership createdAt ascending so users consistently hit their oldest workspace. * Redirect to /{slug} (slug, not UUID). * Removed the unused `objects` / `and` imports that were lint warnings. * Zero-workspace branch redirects to /sign-in?error=no_workspace as a defensive fallback; documented inline that this is unreachable for fresh sign-ins post `ensureUserHasWorkspace` in apps/web/lib/auth.ts. The dashboard at /{slug}/ is no longer a mockup (post commit f64d307 which wired it to objects.stats and objects.listRecent), so landing there now shows real state. Filed plans/Plan-daily-driver-finish/Epic-shipping-the-shell/ Task-onboarding-zero-workspace-flow.md (P2) as the follow-up that turns the defensive fallback into a proper welcome flow with a shared workspace-provisioning helper. `pnpm lint && pnpm type-check` clean. Closes plans/Plan-daily-driver-finish/Epic-shipping-the-shell/ Task-pick-workspace-landing-route.md. Co-authored-by: Cursor --- apps/web/app/page.tsx | 40 ++++++++-- .../Task-onboarding-zero-workspace-flow.md | 73 +++++++++++++++++++ .../Task-pick-workspace-landing-route.md | 27 ++++--- 3 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-onboarding-zero-workspace-flow.md diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 4358b0d..f974339 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,9 +1,27 @@ import { redirect } from "next/navigation"; +import { asc, eq } from "drizzle-orm"; + import { auth } from "@/lib/auth"; import { db } from "@tasks/database/client"; -import { objects, workspaceMembers } from "@tasks/database/schema"; -import { eq, and } from "drizzle-orm"; +import { workspaceMembers, workspaces } from "@tasks/database/schema"; +/** + * Root landing route. Decides where an authenticated user goes when they + * hit `/`. The post-sign-in `callbackUrl` defaults here, so this is the + * single source of truth for "where does a user actually land?" + * + * Decision: land on the user's oldest workspace home (`/{slug}/`). Why oldest: + * we don't have a "last visited" column yet (deliberately deferred — see + * Task-pick-workspace-landing-route) and oldest-membership is stable across + * page loads. The workspace home is no longer a mockup; it now shows real + * stats and recent activity via `objects.stats` / `objects.listRecent`. + * + * The auth callback (`ensureUserHasWorkspace` in `apps/web/lib/auth.ts`) + * provisions a personal workspace on first sign-in, so the zero-workspace + * branch below should be effectively unreachable for fresh sign-ins. It + * survives only as a defensive fallback for sessions that were minted + * before that provisioning logic existed. + */ export default async function HomePage() { const session = await auth(); @@ -11,15 +29,23 @@ export default async function HomePage() { redirect("/sign-in"); } - const membership = await db - .select({ workspaceId: workspaceMembers.workspaceId }) + const [membership] = await db + .select({ slug: workspaces.slug }) .from(workspaceMembers) + .innerJoin(workspaces, eq(workspaces.id, workspaceMembers.workspaceId)) .where(eq(workspaceMembers.userId, session.user.id)) + .orderBy(asc(workspaceMembers.createdAt)) .limit(1); - if (membership.length > 0) { - redirect(`/${membership[0].workspaceId}`); + if (membership?.slug) { + redirect(`/${membership.slug}`); } - redirect("/sign-in"); + // Zero-workspace fallback. See header doc — this should be unreachable + // post-`ensureUserHasWorkspace`. If we hit it anyway, bounce through + // sign-in so the auth callback re-runs and provisions a workspace. + // Track follow-up: a proper onboarding flow for users in this state + // is captured in Plan-daily-driver-finish/Epic-shipping-the-shell/ + // Task-onboarding-zero-workspace-flow.md. + redirect("/sign-in?error=no_workspace"); } diff --git a/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-onboarding-zero-workspace-flow.md b/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-onboarding-zero-workspace-flow.md new file mode 100644 index 0000000..8952ad8 --- /dev/null +++ b/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-onboarding-zero-workspace-flow.md @@ -0,0 +1,73 @@ +--- +kind: task +slug: onboarding-zero-workspace-flow +title: Build a proper onboarding flow for users with zero workspaces +plan_slug: daily-driver-finish +epic_slug: shipping-the-shell +status: ready +priority: P2 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-06-02" +--- + +# Task summary + +When a signed-in user has no workspace membership, `apps/web/app/page.tsx` currently bounces them through `/sign-in?error=no_workspace`. This is a defensive fallback, not a user experience. Build a proper onboarding flow. + +## Description + +### Why this exists + +`apps/web/lib/auth.ts`'s `ensureUserHasWorkspace` provisions a personal workspace on first sign-in, which means the zero-workspace state should be effectively unreachable for new sign-ins. However: + +- Existing JWTs minted before that provisioning logic was added do not trigger `ensureUserHasWorkspace` on refresh; only on a true sign-in event. +- A user could conceivably leave or be removed from every workspace they belonged to (no UI for this yet, but the data model allows it). +- A workspace could be hard-deleted out from under a user (no UI yet, but again — data-model-allowed). + +The current behavior — redirect to `/sign-in?error=no_workspace` — is acceptable as a hidden defensive branch but is jarring for any user who hits it. We want a deliberate flow. + +### The flow + +1. New route: `apps/web/app/(onboarding)/welcome/page.tsx`. Server Component. +2. If user is signed in AND has zero memberships, render a one-step form: "Name your workspace". Default to `${displayName}'s workspace`. +3. Form submit calls a server action that wraps `ensureUserHasWorkspace` (lift it out of `auth.ts` into `apps/web/server/lib/provision-workspace.ts` so both the JWT callback and the server action share the same code). +4. On success, redirect to `/{newSlug}`. +5. `apps/web/app/page.tsx` redirects the zero-membership case to `/welcome` instead of `/sign-in?error=no_workspace`. + +### Out of scope + +- Multi-workspace flow (joining an existing workspace by invite). Belongs to `Plan-multitenant-saas-hardening/Task-workspace-invites-and-roles`. +- Workspace switcher UI. Separate task; not blocking onboarding. + +## Subtasks + +- [ ] Extract `ensureUserHasWorkspace` and `slugifyForWorkspace` from `apps/web/lib/auth.ts` into `apps/web/server/lib/provision-workspace.ts`. Update `auth.ts` to import from there. +- [ ] Add `apps/web/app/(onboarding)/welcome/page.tsx` with the one-step form. +- [ ] Add the server action that calls the shared provisioner. +- [ ] Update `apps/web/app/page.tsx` to redirect zero-membership users to `/welcome` instead of `/sign-in`. + +## Owner or assignee + +Unassigned + +## Status + +ready + +## Estimation + +S + +## Acceptance criteria + +- [ ] Signing in with a session that has no membership shows the welcome form, not a sign-in loop. +- [ ] Submitting the form creates a workspace and lands the user on its home page. +- [ ] The provisioning logic is shared between the JWT callback (first sign-in) and the welcome flow (recovery). + +## Links to related Epic / Plan + +- Epic: `./Epic-shipping-the-shell.md` +- Plan: `../Plan-daily-driver-finish.md` +- Related: `../../Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-workspace-invites-and-roles.md` diff --git a/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-pick-workspace-landing-route.md b/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-pick-workspace-landing-route.md index 07594e0..c1c9317 100644 --- a/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-pick-workspace-landing-route.md +++ b/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-pick-workspace-landing-route.md @@ -4,12 +4,12 @@ slug: pick-workspace-landing-route title: Decide and implement the post-sign-in landing route plan_slug: daily-driver-finish epic_slug: shipping-the-shell -status: ready +status: done priority: P1 tenant_id: global owner: unassigned cursor_todo_id: null -updated_at: "2026-06-01" +updated_at: "2026-06-02" --- # Task summary @@ -41,10 +41,17 @@ Recommendation: **(1) workspace home, but only after** `Task-wire-workspace-home ## Subtasks -- [ ] Read `apps/web/middleware.ts` and `apps/web/app/(app)/layout.tsx` to find the existing landing logic. -- [ ] Decide: workspace home or planner. Document the choice in this task before implementing. -- [ ] Implement the redirect, scoped through `workspace_members`. -- [ ] Verify with a fresh OAuth sign-in that the user lands somewhere useful. +- [x] Located the existing landing logic in `apps/web/app/page.tsx` (no middleware; the App Router root handles it). Previous logic redirected to `/${workspaceId}` (UUID, not slug) and had a sign-in loop for zero-workspace users. +- [x] **Decision: workspace home (`/{slug}/`).** The dashboard now shows real workspace state (post `Task-wire-workspace-home-dashboard`), so this is a real destination instead of a mockup. Planner remains one click away in the icon rail. +- [x] Reimplemented the redirect via an inner join `workspaceMembers ⨝ workspaces`, returning the slug instead of the UUID, ordered by membership `createdAt` ascending (stable across page loads — users always hit the same workspace). +- [x] Removed the unused `objects` / `and` imports that were noisy lint warnings. +- [x] Filed `Task-onboarding-zero-workspace-flow.md` as the follow-up that elevates the defensive `/sign-in?error=no_workspace` fallback into a real onboarding screen with a shared provisioning helper. + +### Decisions made vs. the scaffold + +- **Used slug, not UUID, in the URL.** The previous code redirected to `/${workspaceId}` which worked (because `resolveWorkspace` accepts both) but produced ugly URLs. +- **Ordered by oldest membership.** Postgres has no stable default ordering; without an ORDER BY, users could land on different workspaces between sessions. Oldest is consistent and matches "my primary workspace" intuition. +- **Did not introduce `last_visited_path` column.** The task explicitly anti-goaled the column-add unless we ship the full flow; we're not, so we don't. ## Owner or assignee @@ -52,7 +59,7 @@ Unassigned ## Status -ready +done ## Estimation @@ -60,9 +67,9 @@ S ## Acceptance criteria -- [ ] A fresh sign-in lands on a page that shows real state, not a mockup. -- [ ] Landing logic resolves workspace via `workspace_members` (no URL-trust). -- [ ] Users with zero workspaces don't 404 — they hit an onboarding flow or the workspace-creation dialog. (If neither exists, file a follow-up task and gate this acceptance criterion on it.) +- [x] A fresh sign-in lands on a page that shows real state (workspace home, dashboard wired to `objects.stats` + `objects.listRecent`). +- [x] Landing logic resolves workspace via `workspace_members` (joined to `workspaces` for the slug) — no URL-trust. +- [x] Users with zero workspaces don't 404. They redirect through `/sign-in?error=no_workspace` as a defensive fallback. The proper onboarding flow is captured in `Task-onboarding-zero-workspace-flow.md` as a P2 follow-up; this criterion is gated on that task per the scaffold's allowance. ## Links to related Epic / Plan