Switching Organizations
Users with multiple memberships
Alice is a member of both Acme Corp and Globex Inc. When she makes a request, which organization is she working in? The URL tells us (/orgs/org-acme/notes), but many apps also have a concept of the “active” or “current” organization — the one shown in the UI, used as the default for creating new resources, and displayed in the navigation.
The active organization
In the project setup, the session already includes activeOrgId:
{
userId: string;
activeOrgId: string | null;
createdAt: number;
} When Alice first logs in, activeOrgId is null. She needs to select an organization before she can work. Once selected, the active org is stored in the session and used as the default context.
The switch route
// src/routes/orgs.ts
import { route, group } from "@hectoday/http";
import db from "../db.js";
import { authenticate } from "../auth.js";
import { getMembership, getUserOrgs } from "../membership.js";
import { setActiveOrg } from "../sessions.js";
export const orgRoutes = group([
// List the user's organizations
route.get("/me/orgs", {
resolve: (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const memberships = getUserOrgs(user.id);
const orgs = memberships.map((m) => {
const org = db
.prepare("SELECT id, name FROM organizations WHERE id = ?")
.get(m.orgId) as any;
return { id: org.id, name: org.name, role: m.role };
});
return Response.json({ orgs, activeOrgId: user.activeOrgId });
},
}),
// Switch active organization
route.post("/me/orgs/:orgId/activate", {
resolve: (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const membership = getMembership(user.id, c.params.orgId);
if (!membership) {
return Response.json({ error: "Not found" }, { status: 404 });
}
setActiveOrg(user.sessionId, c.params.orgId);
return Response.json({
activeOrgId: c.params.orgId,
role: membership.role,
});
},
}),
]); GET /me/orgs lists all organizations the user belongs to, with their role in each. POST /me/orgs/:orgId/activate sets the active organization in the session.
The membership check in the activate route is important: a user cannot activate an organization they do not belong to.
Using the active org
Some routes are org-specific (like /orgs/:orgId/notes). Others are “current context” routes that use the active org:
// A convenience route that uses the active org
route.get("/notes", {
resolve: (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
if (!user.activeOrgId) {
return Response.json(
{ error: "No active organization. Use POST /me/orgs/:orgId/activate first." },
{ status: 400 },
);
}
const perm = requirePermission(user, user.activeOrgId, "notes:read");
if (perm instanceof Response) return perm;
const notes = db.prepare("SELECT * FROM notes WHERE org_id = ?").all(user.activeOrgId);
return Response.json(notes);
},
}), This /notes route (without an orgId in the URL) uses the session’s active org. The user must activate an org first.
[!TIP] You can auto-activate the user’s first organization on login. If the user has only one org, set it as active automatically. If they have multiple orgs, prompt them to choose (or use the last active org from a stored preference).
Try it
# Log in as Alice
curl -c cookies.txt -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password123"}'
# List orgs
curl -b cookies.txt http://localhost:3000/me/orgs
# { "orgs": [{ "id": "org-acme", "name": "Acme Corp", "role": "owner" }, ...], "activeOrgId": null }
# Activate Acme
curl -b cookies.txt -X POST http://localhost:3000/me/orgs/org-acme/activate
# Now /notes uses Acme as the context
curl -b cookies.txt http://localhost:3000/notes Exercises
Exercise 1: Log in as Alice, activate Acme, and list notes. Switch to Globex and list notes. You should see different notes for each org.
Exercise 2: Try activating an org Alice is not a member of. The membership check should return 404.
Exercise 3: Auto-activate the first org on login. Modify the login route to find the user’s first membership and call setActiveOrg with it.
Why must the activate route check membership before setting the active org?