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 <cursoragent@cursor.com>
This commit is contained in:
parent
a1e6c863d5
commit
7ec2ede7ca
3 changed files with 123 additions and 17 deletions
|
|
@ -1,9 +1,27 @@
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { db } from "@tasks/database/client";
|
import { db } from "@tasks/database/client";
|
||||||
import { objects, workspaceMembers } from "@tasks/database/schema";
|
import { workspaceMembers, workspaces } from "@tasks/database/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
export default async function HomePage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
|
|
@ -11,15 +29,23 @@ export default async function HomePage() {
|
||||||
redirect("/sign-in");
|
redirect("/sign-in");
|
||||||
}
|
}
|
||||||
|
|
||||||
const membership = await db
|
const [membership] = await db
|
||||||
.select({ workspaceId: workspaceMembers.workspaceId })
|
.select({ slug: workspaces.slug })
|
||||||
.from(workspaceMembers)
|
.from(workspaceMembers)
|
||||||
|
.innerJoin(workspaces, eq(workspaces.id, workspaceMembers.workspaceId))
|
||||||
.where(eq(workspaceMembers.userId, session.user.id))
|
.where(eq(workspaceMembers.userId, session.user.id))
|
||||||
|
.orderBy(asc(workspaceMembers.createdAt))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (membership.length > 0) {
|
if (membership?.slug) {
|
||||||
redirect(`/${membership[0].workspaceId}`);
|
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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
@ -4,12 +4,12 @@ slug: pick-workspace-landing-route
|
||||||
title: Decide and implement the post-sign-in landing route
|
title: Decide and implement the post-sign-in landing route
|
||||||
plan_slug: daily-driver-finish
|
plan_slug: daily-driver-finish
|
||||||
epic_slug: shipping-the-shell
|
epic_slug: shipping-the-shell
|
||||||
status: ready
|
status: done
|
||||||
priority: P1
|
priority: P1
|
||||||
tenant_id: global
|
tenant_id: global
|
||||||
owner: unassigned
|
owner: unassigned
|
||||||
cursor_todo_id: null
|
cursor_todo_id: null
|
||||||
updated_at: "2026-06-01"
|
updated_at: "2026-06-02"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Task summary
|
# Task summary
|
||||||
|
|
@ -41,10 +41,17 @@ Recommendation: **(1) workspace home, but only after** `Task-wire-workspace-home
|
||||||
|
|
||||||
## Subtasks
|
## Subtasks
|
||||||
|
|
||||||
- [ ] Read `apps/web/middleware.ts` and `apps/web/app/(app)/layout.tsx` to find the existing landing logic.
|
- [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.
|
||||||
- [ ] Decide: workspace home or planner. Document the choice in this task before implementing.
|
- [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.
|
||||||
- [ ] Implement the redirect, scoped through `workspace_members`.
|
- [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).
|
||||||
- [ ] Verify with a fresh OAuth sign-in that the user lands somewhere useful.
|
- [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
|
## Owner or assignee
|
||||||
|
|
||||||
|
|
@ -52,7 +59,7 @@ Unassigned
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
ready
|
done
|
||||||
|
|
||||||
## Estimation
|
## Estimation
|
||||||
|
|
||||||
|
|
@ -60,9 +67,9 @@ S
|
||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] A fresh sign-in lands on a page that shows real state, not a mockup.
|
- [x] A fresh sign-in lands on a page that shows real state (workspace home, dashboard wired to `objects.stats` + `objects.listRecent`).
|
||||||
- [ ] Landing logic resolves workspace via `workspace_members` (no URL-trust).
|
- [x] Landing logic resolves workspace via `workspace_members` (joined to `workspaces` for the slug) — 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] 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
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue