feat: consolidate web auth and add Effect v4 Project primitives
Auth:
- Add useWebAuth hook as the single web-facing auth interface with four
states (loading/unauthenticated/authenticated/inconsistent)
- Normalize getCurrentUser to return {id: tokenIdentifier, name, email}
- Rename requireOwnerId to requireAuthUserId across all backend callers
- Set expectAuth:true on ConvexReactClient singleton
- Migrate _app, _auth, user-menu, use-personal-organization to useWebAuth
- Remove unused @code/auth/server export (zero callers confirmed)
Primitives:
- Add packages/primitives/src/project.ts with Effect v4 Project domain:
URL normalization, six canonical context kinds, strict remote decoding,
context write decisioning with revision tracking, and no-op detection
- Define PublicGit and ProjectStore replaceable ports
- Define ProjectApplication service orchestrating domain+ports with
deterministic error mapping
- Add comprehensive test suite (28 tests) with in-memory store and fake
PublicGit covering normalization, seeds, idempotency, tenant isolation,
last-write-wins, and typed error mapping
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { authClient } from "@code/auth/web";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import { signOutWeb, useWebAuth } from "@code/auth/web";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,12 +9,13 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@code/ui/components/dropdown-menu";
|
||||
import { useQuery } from "convex/react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function UserMenu() {
|
||||
const navigate = useNavigate();
|
||||
const user = useQuery(api.auth.getCurrentUser);
|
||||
const auth = useWebAuth();
|
||||
|
||||
const user = auth.status === "authenticated" ? auth.user : null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -30,12 +30,8 @@ export default function UserMenu() {
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
navigate("/login", { replace: true });
|
||||
},
|
||||
},
|
||||
void signOutWeb().then(() => {
|
||||
navigate("/login", { replace: true });
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -6,18 +6,18 @@ import { usePersonalOrganization } from "./use-personal-organization";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
ensurePersonalOrganization: vi.fn(),
|
||||
session: {
|
||||
data: { session: { id: "session-a" } },
|
||||
} as { data: { session: { id: string } } | null },
|
||||
state: null as {
|
||||
error?: Error;
|
||||
organizationId?: string;
|
||||
sessionId: string;
|
||||
} | null,
|
||||
authState: {
|
||||
status: "unauthenticated",
|
||||
} as
|
||||
| { status: "unauthenticated" }
|
||||
| { status: "authenticated"; user: { id: string } },
|
||||
orgId: undefined as string | undefined,
|
||||
orgError: undefined as Error | undefined,
|
||||
setterIndex: 0,
|
||||
}));
|
||||
|
||||
vi.mock("@code/auth/web", () => ({
|
||||
authClient: { useSession: () => mocks.session },
|
||||
useWebAuth: () => mocks.authState,
|
||||
}));
|
||||
vi.mock("@code/backend/convex/_generated/api", () => ({
|
||||
api: { organizations: { ensurePersonalOrganization: "ensure-org" } },
|
||||
@@ -25,18 +25,36 @@ vi.mock("@code/backend/convex/_generated/api", () => ({
|
||||
vi.mock("convex/react", () => ({
|
||||
useMutation: () => mocks.ensurePersonalOrganization,
|
||||
}));
|
||||
|
||||
// useState is called twice: first for organizationId, second for error.
|
||||
// We give each its own state cell so the test can read them independently.
|
||||
const stateCells = {
|
||||
error: undefined as Error | undefined,
|
||||
orgId: undefined as string | undefined,
|
||||
};
|
||||
|
||||
vi.mock("react", () => ({
|
||||
useEffect: (effect: EffectCallback) => {
|
||||
mocks.cleanup?.();
|
||||
const cleanup = effect();
|
||||
mocks.cleanup = typeof cleanup === "function" ? cleanup : undefined;
|
||||
},
|
||||
useState: () => [
|
||||
mocks.state,
|
||||
(next: { error?: Error; organizationId?: string; sessionId: string }) => {
|
||||
mocks.state = next;
|
||||
},
|
||||
],
|
||||
useState: <T>(initial: T) => {
|
||||
const callIndex = mocks.setterIndex;
|
||||
mocks.setterIndex = (mocks.setterIndex + 1) % 2;
|
||||
const cellKey = callIndex === 0 ? "orgId" : "error";
|
||||
const current =
|
||||
cellKey === "orgId" ? stateCells.orgId : (stateCells.error as T);
|
||||
const value = current === undefined ? initial : current;
|
||||
const setter = (next: T) => {
|
||||
if (cellKey === "orgId") {
|
||||
stateCells.orgId = next as string | undefined;
|
||||
} else {
|
||||
stateCells.error = next as Error | undefined;
|
||||
}
|
||||
};
|
||||
return [value, setter];
|
||||
},
|
||||
}));
|
||||
|
||||
describe("usePersonalOrganization", () => {
|
||||
@@ -44,34 +62,31 @@ describe("usePersonalOrganization", () => {
|
||||
mocks.cleanup?.();
|
||||
mocks.cleanup = undefined;
|
||||
mocks.ensurePersonalOrganization.mockReset();
|
||||
mocks.session = { data: { session: { id: "session-a" } } };
|
||||
mocks.state = null;
|
||||
mocks.authState = { status: "unauthenticated" };
|
||||
mocks.setterIndex = 0;
|
||||
stateCells.error = undefined;
|
||||
stateCells.orgId = undefined;
|
||||
});
|
||||
|
||||
test("an authenticated session ensures its org before exposing the id", async () => {
|
||||
mocks.authState = { status: "authenticated", user: { id: "user-a" } };
|
||||
const ensured = Promise.withResolvers<{ _id: string }>();
|
||||
mocks.ensurePersonalOrganization.mockReturnValue(ensured.promise);
|
||||
|
||||
expect(usePersonalOrganization()).toEqual({});
|
||||
usePersonalOrganization();
|
||||
expect(mocks.ensurePersonalOrganization).toHaveBeenCalledTimes(1);
|
||||
|
||||
ensured.resolve({ _id: "org-a" });
|
||||
await ensured.promise;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(usePersonalOrganization()).toEqual({
|
||||
error: undefined,
|
||||
organizationId: "org-a",
|
||||
});
|
||||
expect(stateCells.orgId).toBe("org-a");
|
||||
});
|
||||
|
||||
test("logout cannot expose the prior session organization", async () => {
|
||||
mocks.ensurePersonalOrganization.mockResolvedValue({ _id: "org-a" });
|
||||
usePersonalOrganization();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
test("an unauthenticated session never ensures an org", () => {
|
||||
mocks.authState = { status: "unauthenticated" };
|
||||
|
||||
mocks.session = { data: null };
|
||||
expect(usePersonalOrganization()).toEqual({});
|
||||
expect(mocks.ensurePersonalOrganization).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
import { authClient } from "@code/auth/web";
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface OrganizationBootstrapState {
|
||||
readonly error?: Error;
|
||||
readonly organizationId?: Id<"organizations">;
|
||||
readonly sessionId: string;
|
||||
}
|
||||
|
||||
export interface PersonalOrganizationState {
|
||||
readonly error?: Error;
|
||||
readonly organizationId?: Id<"organizations">;
|
||||
}
|
||||
|
||||
/** Ensure the authenticated session has its personal tenancy boundary. */
|
||||
/** Ensure the authenticated user has its personal tenancy boundary. */
|
||||
export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
const { data: session } = authClient.useSession();
|
||||
const sessionId = session?.session.id;
|
||||
const auth = useWebAuth();
|
||||
const userId = auth.status === "authenticated" ? auth.user.id : null;
|
||||
const ensurePersonalOrganization = useMutation(
|
||||
api.organizations.ensurePersonalOrganization
|
||||
);
|
||||
const [bootstrap, setBootstrap] = useState<OrganizationBootstrapState | null>(
|
||||
null
|
||||
);
|
||||
const [organizationId, setOrganizationId] = useState<
|
||||
Id<"organizations"> | undefined
|
||||
>();
|
||||
const [error, setError] = useState<Error | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
if (!userId) {
|
||||
setOrganizationId(undefined);
|
||||
setError(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,20 +33,16 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
try {
|
||||
const organization = await ensurePersonalOrganization();
|
||||
if (active) {
|
||||
setBootstrap({
|
||||
organizationId: organization._id,
|
||||
sessionId,
|
||||
});
|
||||
setOrganizationId(organization._id);
|
||||
setError(undefined);
|
||||
}
|
||||
} catch (caughtError: unknown) {
|
||||
if (active) {
|
||||
setBootstrap({
|
||||
error:
|
||||
caughtError instanceof Error
|
||||
? caughtError
|
||||
: new Error(String(caughtError)),
|
||||
sessionId,
|
||||
});
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError
|
||||
: new Error(String(caughtError))
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -58,13 +51,11 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [ensurePersonalOrganization, sessionId]);
|
||||
}, [ensurePersonalOrganization, userId]);
|
||||
|
||||
if (!sessionId || bootstrap?.sessionId !== sessionId) {
|
||||
if (!userId) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
error: bootstrap.error,
|
||||
organizationId: bootstrap.organizationId,
|
||||
};
|
||||
|
||||
return { error, organizationId };
|
||||
};
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { useConvexAuth } from "convex/react";
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { Navigate, Outlet, useLocation } from "react-router";
|
||||
|
||||
export default function AppLayout() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||
const auth = useWebAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
if (auth.status === "loading") {
|
||||
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (auth.status === "inconsistent") {
|
||||
return (
|
||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
||||
Your session could not be verified
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.status === "unauthenticated") {
|
||||
return <Navigate replace state={{ from: location.pathname }} to="/login" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useConvexAuth } from "convex/react";
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { Navigate, Outlet } from "react-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||
const auth = useWebAuth();
|
||||
|
||||
if (isLoading) {
|
||||
if (auth.status === "loading") {
|
||||
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
|
||||
if (isAuthenticated) return <Navigate replace to="/" />;
|
||||
if (auth.status === "authenticated") return <Navigate replace to="/" />;
|
||||
|
||||
return (
|
||||
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
|
||||
|
||||
Reference in New Issue
Block a user