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
46 changes: 38 additions & 8 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"@graphql-codegen/typescript-graphql-request": "^6.4.0",
"@graphql-codegen/typescript-msw": "^3.0.1",
"@graphql-codegen/typescript-operations": "^5.0.7",
"@graphql-codegen/typescript-react-query": "^6.1.1",
"@graphql-codegen/typescript-react-query": "^7.0.5",
"@graphql-typed-document-node/core": "^3.2.0",
"@happy-dom/global-registrator": "^20.3.7",
"@playwright/test": "^1.58.0",
Expand Down Expand Up @@ -70,7 +70,7 @@
"dependencies": {
"@ark-ui/react": "^5.30.0",
"@icons-pack/react-simple-icons": "^13.8.0",
"@omnidotdev/providers": "github:omnidotdev/providers#49f2fc4",
"@omnidotdev/providers": "github:omnidotdev/providers#67cb527",
"@omnidotdev/thornberry": "github:omnidotdev/thornberry#eb634c7",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/nitro-v2-vite-plugin": "^1.154.7",
Expand Down
219 changes: 219 additions & 0 deletions src/components/organizations/CreateOrganizationButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { useCreateWorkspace } from "@omnidotdev/providers/react";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { useCallback, useEffect, useState } from "react";

import { generateSlug } from "@/lib/util";
import { fetchSession } from "@/server/functions/auth";
import {
checkOrganizationHandleAvailability,
createOrganization,
} from "@/server/functions/organizations";

import type { ReactNode } from "react";
import type { Organization } from "@/server/functions/organizations";

type Availability = "unknown" | "checking" | "available" | "taken";

const MIN_HANDLE_LENGTH = 3;

const TRIGGER_CLASS =
"inline-flex h-9 items-center gap-2 rounded-md border bg-background px-4 font-medium text-sm shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground";

interface Props {
/** Trigger label (defaults to "New organization") */
children?: ReactNode;
/** Override the trigger button classes */
className?: string;
}

/**
* Self-contained "create organization" trigger + modal.
*
* Drives the ecosystem-wide creation flow (`useCreateWorkspace` from
* `@omnidotdev/providers`): a live namespace availability check, the
* `createOrganization` server fn (the Gatekeeper client must run server-side),
* and `fetchSession` to refresh claims. Deliberately dependency-light (native
* elements + Tailwind tokens) so it drops into any app regardless of its design
* system version. On success it lands the user in the new org, which resolves
* immediately via the route's `getOrganizationBySlug` fallback while the JWT
* claims catch up.
*/
const CreateOrganizationButton = ({ children, className }: Props) => {
const router = useRouter();
const navigate = useNavigate();

const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [availability, setAvailability] = useState<Availability>("unknown");
const [error, setError] = useState<string | null>(null);

const slug = name.trim() ? generateSlug(name.trim()) : "";

const { create, isCreating } = useCreateWorkspace<Organization>({
checkAvailability: (handle) =>
checkOrganizationHandleAvailability({ data: { slug: handle } }),
createWorkspace: (input) => createOrganization({ data: input }),
refreshSession: () => fetchSession(),
});

// Live availability check, debounced. The flow re-checks at submit; this is
// purely for inline feedback while typing.
useEffect(() => {
if (slug.length < MIN_HANDLE_LENGTH) {
setAvailability("unknown");
return;
}

setAvailability("checking");
let cancelled = false;
const timer = setTimeout(async () => {
try {
const { available } = await checkOrganizationHandleAvailability({
data: { slug },
});
if (!cancelled) setAvailability(available ? "available" : "taken");
} catch {
if (!cancelled) setAvailability("unknown");
}
}, 500);

return () => {
cancelled = true;
clearTimeout(timer);
};
}, [slug]);

const close = useCallback(() => {
setOpen(false);
setName("");
setAvailability("unknown");
setError(null);
}, []);

useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, close]);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);

try {
const org = await create({ name: name.trim(), slug });
if (!org) return;

close();

await router.invalidate();
navigate({
to: "/organizations/$orgSlug",
params: { orgSlug: org.slug },
});
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to create organization",
);
}
};

const canSubmit =
slug.length >= MIN_HANDLE_LENGTH &&
availability !== "taken" &&
availability !== "checking" &&
!isCreating;

return (
<>
<button
type="button"
className={className ?? TRIGGER_CLASS}
onClick={() => setOpen(true)}
>
{children ?? "New organization"}
</button>

{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-lg">
<div className="flex items-start justify-between gap-4">
<h2 className="font-semibold text-lg">Create organization</h2>
<button
type="button"
onClick={close}
aria-label="Close"
className="text-muted-foreground text-sm hover:text-foreground"
>
</button>
</div>
<p className="mt-1 text-muted-foreground text-sm">
An organization is where your projects and workspaces live.
</p>

<form onSubmit={handleSubmit} className="mt-4 flex flex-col gap-3">
<input
// biome-ignore lint/a11y/noAutofocus: focus the sole field when the modal opens
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Organization name"
autoComplete="off"
className="h-9 rounded-md border bg-background px-3 text-sm"
/>

{slug && (
<p className="text-muted-foreground text-xs">
URL: <span className="font-mono">{slug}</span>
</p>
)}

{slug.length >= MIN_HANDLE_LENGTH &&
availability === "available" && (
<p className="text-green-600 text-xs">Available</p>
)}

{slug.length >= MIN_HANDLE_LENGTH && availability === "taken" && (
<p className="text-destructive text-xs">
That handle is already taken. Handles are unique across Omni.
</p>
)}

{error && <p className="text-destructive text-xs">{error}</p>}

<p className="text-muted-foreground text-xs">
Your organization is part of your Omni account and works across
Omni products.
</p>

<div className="mt-2 flex justify-end gap-2">
<button
type="button"
onClick={close}
disabled={isCreating}
className={TRIGGER_CLASS}
>
Cancel
</button>

<button
type="submit"
disabled={!canSubmit}
className="inline-flex h-9 items-center rounded-md bg-primary px-4 font-medium text-primary-foreground text-sm shadow-xs transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{isCreating ? "Creating..." : "Create organization"}
</button>
</div>
</form>
</div>
</div>
)}
</>
);
};

export default CreateOrganizationButton;
47 changes: 37 additions & 10 deletions src/routes/_auth/organizations/$orgSlug.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { useEffect } from "react";

import { useOrganization } from "@/lib/context";
import { getOrganizationBySlug } from "@/server/functions/organizations";

import type { Organization } from "@/lib/context";

export const Route = createFileRoute("/_auth/organizations/$orgSlug")({
beforeLoad: async ({ params }) => {
// Validate org slug exists in user's organizations
// This will be populated from context in a real app
return { orgSlug: params.orgSlug };
},
component: OrgLayout,
Expand All @@ -17,9 +20,39 @@ export const Route = createFileRoute("/_auth/organizations/$orgSlug")({
*/
function OrgLayout() {
const { orgSlug } = Route.useParams();
const { organizations, setActiveOrganization } = useOrganization();
const { organizations, activeOrganization, setActiveOrganization } =
useOrganization();

const claimOrg = organizations.find((o) => o.slug === orgSlug);

// A just-created organization is not yet in the JWT claims (the org list is
// hydrated from a short-lived cache), so fall back to a live Gatekeeper lookup
// until claims catch up. Skipped once the org is present in claims.
const { data: fallbackOrg, isLoading: isResolvingFallback } = useQuery({
queryKey: ["organization-fallback", orgSlug],
queryFn: () => getOrganizationBySlug({ data: { slug: orgSlug } }),
enabled: !claimOrg,
});

const org: Organization | undefined =
claimOrg ??
(fallbackOrg
? {
id: fallbackOrg.id,
slug: fallbackOrg.slug,
type: fallbackOrg.type,
roles: [],
teams: [],
}
: undefined);

const org = organizations.find((o) => o.slug === orgSlug);
useEffect(() => {
if (claimOrg && activeOrganization?.id !== claimOrg.id) {
setActiveOrganization(claimOrg.id);
}
}, [claimOrg, activeOrganization?.id, setActiveOrganization]);

if (!org && isResolvingFallback) return null;

if (!org) {
return (
Expand All @@ -34,11 +67,5 @@ function OrgLayout() {
);
}

// Set as active organization when viewing
// useEffect would be better here in a real app
if (org.id !== organizations.find((o) => o.slug === orgSlug)?.id) {
setActiveOrganization(org.id);
}

return <Outlet />;
}
17 changes: 12 additions & 5 deletions src/routes/_auth/organizations/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Link, createFileRoute } from "@tanstack/react-router";

import CreateOrganizationButton from "@/components/organizations/CreateOrganizationButton";
import { useOrganization } from "@/lib/context";

export const Route = createFileRoute("/_auth/organizations/")({
Expand All @@ -15,7 +16,11 @@ function OrganizationsPage() {

return (
<div className="container mx-auto py-8">
<h1 className="mb-6 font-bold text-2xl">Organizations</h1>
<div className="mb-6 flex items-center justify-between gap-4">
<h1 className="font-bold text-2xl">Organizations</h1>

{organizations.length > 0 && <CreateOrganizationButton />}
</div>

<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{organizations.map((org) => (
Expand Down Expand Up @@ -46,10 +51,12 @@ function OrganizationsPage() {
</div>

{organizations.length === 0 && (
<p className="text-muted-foreground">
No organizations found. You should have at least a personal
organization.
</p>
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed py-16 text-center">
<p className="text-muted-foreground text-sm">
No organizations yet. Create one to get started.
</p>
<CreateOrganizationButton />
</div>
)}
</div>
);
Expand Down
26 changes: 21 additions & 5 deletions src/server/functions/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,32 @@ import { z } from "zod";
import { authz } from "@/lib/providers";
import { authMiddleware } from "@/server/middleware";

import type {
PermissionCheck,
WardenRelation,
WardenResourceType,
} from "@omnidotdev/providers/authz";

// The authz provider types resourceType/permission as the Warden resource and
// relation unions. Validate they are strings at runtime (the provider enforces
// the actual values) while typing them to satisfy the strongly-typed API
const resourceType = z.custom<WardenResourceType>((v) => typeof v === "string");
const permission = z.custom<WardenRelation<WardenResourceType>>(
(v) => typeof v === "string",
);

const checkPermissionSchema = z.object({
resourceType: z.string(),
resourceType,
resourceId: z.string().uuid(),
permission: z.string(),
permission,
});

const batchCheckSchema = z.object({
checks: z.array(
z.object({
resourceType: z.string(),
resourceType,
resourceId: z.string().uuid(),
permission: z.string(),
permission,
}),
),
});
Expand Down Expand Up @@ -59,10 +73,12 @@ export const batchCheckPermissions = createServerFn()
}

const results = await authz.checkPermissionsBatch(
// Runtime-valid checks; cast bridges the broad validated unions to the
// provider's discriminated PermissionCheck type
data.checks.map((check) => ({
userId: context.session.user.id,
...check,
})),
})) as PermissionCheck[],
);
return results.map((r) => r.allowed);
});
Loading
Loading