From 3322e6b055d9a178d61a677e968a641340d86c04 Mon Sep 17 00:00:00 2001 From: Brian Cooper Date: Tue, 14 Jul 2026 19:03:00 -0700 Subject: [PATCH] feat(organizations): add a first-class create-organization flow Adds the shared create-organization flow (useCreateWorkspace from providers) to the template so every app scaffolded from it ships a create path: a dependency-light, toast-free modal with live handle availability, an organizations-list header/empty-state button, and a getOrganizationBySlug fallback so a new org resolves before it lands in JWT claims. Bumps @omnidotdev/providers to 67cb527 for useCreateWorkspace/checkNamespaceAvailability. That bump tightens the authz permission API (WardenResourceType/WardenRelation), so authorization.ts is migrated to the typed signature. --- bun.lock | 46 +++- package.json | 4 +- .../CreateOrganizationButton.tsx | 219 ++++++++++++++++++ src/routes/_auth/organizations/$orgSlug.tsx | 47 +++- src/routes/_auth/organizations/index.tsx | 17 +- src/server/functions/authorization.ts | 26 ++- src/server/functions/organizations.ts | 18 +- 7 files changed, 346 insertions(+), 31 deletions(-) create mode 100644 src/components/organizations/CreateOrganizationButton.tsx diff --git a/bun.lock b/bun.lock index a7116b5..88dda0e 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,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", @@ -45,7 +45,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", @@ -421,7 +421,7 @@ "@graphql-codegen/typescript-operations": ["@graphql-codegen/typescript-operations@5.0.7", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.1.0", "@graphql-codegen/typescript": "^5.0.7", "@graphql-codegen/visitor-plugin-common": "6.2.2", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "graphql-sock": "^1.0.0" }, "optionalPeers": ["graphql-sock"] }, "sha512-5N3myNse1putRQlp8+l1k9ayvc98oq2mPJx0zN8MTOlTBxcb2grVPFRLy5wJJjuv9NffpyCkVJ9LvUaf8mqQgg=="], - "@graphql-codegen/typescript-react-query": ["@graphql-codegen/typescript-react-query@6.1.1", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^3.0.0", "@graphql-codegen/visitor-plugin-common": "2.13.8", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-knSlUFmq7g7G2DIa5EGjOnwWtNfpU4k+sXWJkxdwJ7lU9nrw6pnDizJcjHCqKelRmk2xwfspVNzu0KoXP7LLsg=="], + "@graphql-codegen/typescript-react-query": ["@graphql-codegen/typescript-react-query@7.0.5", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^7.0.1", "@graphql-codegen/visitor-plugin-common": "^7.1.1", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-CcqohWg47jsSY3HFkbrzK/UE8CaCIdmeXR3uVYyCmYBXzPUB6kP6OLP1BVcb7KBPLQC2XCuiwAV07NCumKfHqw=="], "@graphql-codegen/visitor-plugin-common": ["@graphql-codegen/visitor-plugin-common@2.13.8", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^3.1.2", "@graphql-tools/optimize": "^1.3.0", "@graphql-tools/relay-operation-optimizer": "^6.5.0", "@graphql-tools/utils": "^9.0.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "dependency-graph": "^0.11.0", "graphql-tag": "^2.11.0", "parse-filepath": "^1.0.2", "tslib": "~2.4.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-IQWu99YV4wt8hGxIbBQPtqRuaWZhkQRG2IZKbMoSvh0vGeWb3dB0n0hSgKaOOxDY+tljtOf9MTcUYvJslQucMQ=="], @@ -679,7 +679,7 @@ "@nuxtjs/color-mode": ["@nuxtjs/color-mode@3.5.2", "", { "dependencies": { "@nuxt/kit": "^3.13.2", "pathe": "^1.1.2", "pkg-types": "^1.2.1", "semver": "^7.6.3" } }, "sha512-cC6RfgZh3guHBMLLjrBB2Uti5eUoGM9KyauOaYS9ETmxNWBMTvpgjvSiSJp1OFljIXPIqVTJ3xtJpSNZiO3ZaA=="], - "@omnidotdev/providers": ["@omnidotdev/providers@github:omnidotdev/providers#49f2fc4", { "dependencies": { "ajv": "^8.18.0", "jose": "^6.1.3" }, "peerDependencies": { "@aws-sdk/client-s3": ">=3.0.0", "@aws-sdk/s3-request-presigner": ">=3.0.0", "@envelop/types": ">=5.0.0", "@escape.tech/graphql-armor": ">=3.0.0", "@iggy.rs/sdk": ">=1.0.0", "@openfeature/server-sdk": ">=1.0.0", "@tanstack/query-core": ">=5.0.0", "ajv": ">=8.0.0", "graphile-export": ">=1.0.0-rc.0", "postgraphile": ">=5.0.0-rc.0", "react": ">=19.0.0", "unleash-client": ">=6.0.0" }, "optionalPeers": ["@aws-sdk/client-s3", "@aws-sdk/s3-request-presigner", "@envelop/types", "@escape.tech/graphql-armor", "@iggy.rs/sdk", "@openfeature/server-sdk", "@tanstack/query-core", "ajv", "graphile-export", "postgraphile", "react", "unleash-client"] }, "omnidotdev-providers-49f2fc4", "sha512-XyTp7f+ZgZSh8mkMMl/x/Y2Oo1y/O984HAXCmPFH21Orrzmi6Ak2E45RduwHp6aeuGSg1cMELoOna3Tdj0QXrg=="], + "@omnidotdev/providers": ["@omnidotdev/providers@github:omnidotdev/providers#67cb527", { "dependencies": { "ajv": "^8.18.0", "jose": "^6.1.3" }, "peerDependencies": { "@aws-sdk/client-s3": ">=3.0.0", "@aws-sdk/s3-request-presigner": ">=3.0.0", "@envelop/types": ">=5.0.0", "@escape.tech/graphql-armor": ">=3.0.0", "@iggy.rs/sdk": ">=1.0.0", "@openfeature/server-sdk": ">=1.0.0", "@tanstack/query-core": ">=5.0.0", "ajv": ">=8.0.0", "graphile-export": ">=1.0.0-rc.0", "postgraphile": ">=5.0.0-rc.0", "react": ">=19.0.0", "unleash-client": ">=6.0.0" }, "optionalPeers": ["@aws-sdk/client-s3", "@aws-sdk/s3-request-presigner", "@envelop/types", "@escape.tech/graphql-armor", "@iggy.rs/sdk", "@openfeature/server-sdk", "@tanstack/query-core", "ajv", "graphile-export", "postgraphile", "react", "unleash-client"] }, "omnidotdev-providers-67cb527", "sha512-IPM2pytXkWc0Hn1BOwzz8LYPrXWawazAl+BDapHQ6HKl9fPPNqLjnHmNDsGPEFgxbN+Fi+wo5nov1pvRcDQFAA=="], "@omnidotdev/thornberry": ["@omnidotdev/thornberry@github:omnidotdev/thornberry#eb634c7", { "peerDependencies": { "@ark-ui/react": ">=5.0.0", "@lexical/html": ">=0.39.0", "@lexical/link": ">=0.39.0", "@lexical/list": ">=0.39.0", "@lexical/markdown": ">=0.39.0", "@lexical/react": ">=0.39.0", "@lexical/rich-text": ">=0.39.0", "@unpic/react": ">=1.0.0", "@xyflow/react": ">=12.0.0", "cmdk": ">=1.0.0", "isomorphic-dompurify": ">=3.0.0", "lexical": ">=0.39.0", "lucide-react": ">=0.500.0", "react": ">=19.0.0", "react-dom": ">=19.0.0", "react-hotkeys-hook": ">=5.0.0" } }, "omnidotdev-thornberry-eb634c7", "sha512-1T1sXjE4IYc+FGCXuXN5dGGjQ6W0UD1wPd5KEHGjzMpcxjG1Ru5BdMCgSl9Y1v/jzK0GhLT2/yDi+K5++vDFtA=="], @@ -3263,7 +3263,9 @@ "@graphql-codegen/typescript-operations/@graphql-codegen/visitor-plugin-common": ["@graphql-codegen/visitor-plugin-common@6.2.2", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.1.0", "@graphql-tools/optimize": "^2.0.0", "@graphql-tools/relay-operation-optimizer": "^7.0.0", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "dependency-graph": "^1.0.0", "graphql-tag": "^2.11.0", "parse-filepath": "^1.0.2", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-wEJ4zJj58PKlXISItZfr0xIHyM1lAuRfoflPegsb1L17Mx5+YzNOy0WAlLele3yzyV89WvCiprFKMcVQ7KfDXg=="], - "@graphql-codegen/typescript-react-query/@graphql-codegen/plugin-helpers": ["@graphql-codegen/plugin-helpers@3.1.2", "", { "dependencies": { "@graphql-tools/utils": "^9.0.0", "change-case-all": "1.0.15", "common-tags": "1.8.2", "import-from": "4.0.0", "lodash": "~4.17.0", "tslib": "~2.4.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-emOQiHyIliVOIjKVKdsI5MXj312zmRDwmHpyUTZMjfpvxq/UVAHUJIVdVf+lnjjrI+LXBTgMlTWTgHQfmICxjg=="], + "@graphql-codegen/typescript-react-query/@graphql-codegen/plugin-helpers": ["@graphql-codegen/plugin-helpers@7.1.0", "", { "dependencies": { "@graphql-tools/utils": "^11.2.0", "change-case-all": "^2.1.0", "common-tags": "1.8.2", "import-from": "4.0.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-ieJH7kZ5oSZKBPJs7CvHMrFY/CLYLklqv74ir93qMwRna6geZsbIMoJzTDBXohxcQTITiProiYSGrEtZjIpYGg=="], + + "@graphql-codegen/typescript-react-query/@graphql-codegen/visitor-plugin-common": ["@graphql-codegen/visitor-plugin-common@7.2.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^7.1.0", "@graphql-tools/optimize": "^2.0.0", "@graphql-tools/relay-operation-optimizer": "^7.1.1", "@graphql-tools/utils": "^11.2.0", "auto-bind": "^5.0.0", "change-case-all": "^2.1.0", "dependency-graph": "^1.0.0", "graphql-tag": "^2.11.0", "parse-filepath": "^1.0.2", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-qqtTY8taONuxlR0HvD8z+zgWC3CfJ47M2rATx+geWNIN8v8Itc5TflHYxjkWAtNL6E3q7WZRY5P7se+2S3EjBg=="], "@graphql-codegen/typescript-react-query/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -3851,9 +3853,21 @@ "@graphql-codegen/typescript-operations/@graphql-codegen/visitor-plugin-common/dependency-graph": ["dependency-graph@1.0.0", "", {}, "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg=="], - "@graphql-codegen/typescript-react-query/@graphql-codegen/plugin-helpers/@graphql-tools/utils": ["@graphql-tools/utils@9.2.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A=="], + "@graphql-codegen/typescript-react-query/@graphql-codegen/plugin-helpers/@graphql-tools/utils": ["@graphql-tools/utils@11.2.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-eu9h1R3j/wWc4rvmYJF5AKtlwniDzstrZ/c6KSz+HdI+n7I7iog9xyKmBfpUwSbG1TqPNZBzWjFMkzdYOKq6Bg=="], + + "@graphql-codegen/typescript-react-query/@graphql-codegen/plugin-helpers/change-case-all": ["change-case-all@2.1.0", "", { "dependencies": { "change-case": "^5.2.0", "sponge-case": "^2.0.2", "swap-case": "^3.0.2", "title-case": "^3.0.3" } }, "sha512-v6b0WWWkZUMHVuYk82l+WROgkUm4qEN2w5hKRNWtEOYwWqUGoi8C6xH0l1RLF1EoWqDFK6MFclmN3od6ws3/uw=="], + + "@graphql-codegen/typescript-react-query/@graphql-codegen/visitor-plugin-common/@graphql-tools/optimize": ["@graphql-tools/optimize@2.0.0", "", { "dependencies": { "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-nhdT+CRGDZ+bk68ic+Jw1OZ99YCDIKYA5AlVAnBHJvMawSx9YQqQAIj4refNc1/LRieGiuWvhbG3jvPVYho0Dg=="], + + "@graphql-codegen/typescript-react-query/@graphql-codegen/visitor-plugin-common/@graphql-tools/relay-operation-optimizer": ["@graphql-tools/relay-operation-optimizer@7.1.6", "", { "dependencies": { "@ardatan/relay-compiler": "^13.0.1", "@graphql-tools/utils": "^11.2.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-uh4ikON4vCb95M8pjGRckucCAm7eq6TB9FXP0zZByaSO6dFkTR1jKrOSWrJ2qx+IR8B6bVaR/Zst9vGs26ZjXw=="], - "@graphql-codegen/typescript-react-query/@graphql-codegen/plugin-helpers/tslib": ["tslib@2.4.1", "", {}, "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA=="], + "@graphql-codegen/typescript-react-query/@graphql-codegen/visitor-plugin-common/@graphql-tools/utils": ["@graphql-tools/utils@11.2.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-eu9h1R3j/wWc4rvmYJF5AKtlwniDzstrZ/c6KSz+HdI+n7I7iog9xyKmBfpUwSbG1TqPNZBzWjFMkzdYOKq6Bg=="], + + "@graphql-codegen/typescript-react-query/@graphql-codegen/visitor-plugin-common/auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + + "@graphql-codegen/typescript-react-query/@graphql-codegen/visitor-plugin-common/change-case-all": ["change-case-all@2.1.0", "", { "dependencies": { "change-case": "^5.2.0", "sponge-case": "^2.0.2", "swap-case": "^3.0.2", "title-case": "^3.0.3" } }, "sha512-v6b0WWWkZUMHVuYk82l+WROgkUm4qEN2w5hKRNWtEOYwWqUGoi8C6xH0l1RLF1EoWqDFK6MFclmN3od6ws3/uw=="], + + "@graphql-codegen/typescript-react-query/@graphql-codegen/visitor-plugin-common/dependency-graph": ["dependency-graph@1.0.0", "", {}, "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg=="], "@graphql-codegen/typescript/@graphql-codegen/visitor-plugin-common/@graphql-tools/optimize": ["@graphql-tools/optimize@2.0.0", "", { "dependencies": { "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-nhdT+CRGDZ+bk68ic+Jw1OZ99YCDIKYA5AlVAnBHJvMawSx9YQqQAIj4refNc1/LRieGiuWvhbG3jvPVYho0Dg=="], @@ -4175,7 +4189,19 @@ "@graphql-codegen/typescript-operations/@graphql-codegen/visitor-plugin-common/@graphql-tools/relay-operation-optimizer/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@graphql-codegen/typescript-react-query/@graphql-codegen/plugin-helpers/@graphql-tools/utils/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@graphql-codegen/typescript-react-query/@graphql-codegen/plugin-helpers/change-case-all/change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="], + + "@graphql-codegen/typescript-react-query/@graphql-codegen/plugin-helpers/change-case-all/sponge-case": ["sponge-case@2.0.3", "", {}, "sha512-i4h9ZGRfxV6Xw3mpZSFOfbXjf0cQcYmssGWutgNIfFZ2VM+YIWfD71N/kjjwK6X/AAHzBr+rciEcn/L34S8TGw=="], + + "@graphql-codegen/typescript-react-query/@graphql-codegen/plugin-helpers/change-case-all/swap-case": ["swap-case@3.0.3", "", {}, "sha512-6p4op8wE9CQv7uDFzulI6YXUw4lD9n4oQierdbFThEKVWVQcbQcUjdP27W8XE7V4QnWmnq9jueSHceyyQnqQVA=="], + + "@graphql-codegen/typescript-react-query/@graphql-codegen/visitor-plugin-common/@graphql-tools/relay-operation-optimizer/@ardatan/relay-compiler": ["@ardatan/relay-compiler@13.0.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "immutable": "^5.1.5", "invariant": "^2.2.4" }, "peerDependencies": { "graphql": "*" } }, "sha512-afG3YPwuSA0E5foouZusz5GlXKs74dObv4cuWyLyfKsYFj2r7oGRNB28v18HvwuLSQtQFCi+DpIe0TZkgQDYyg=="], + + "@graphql-codegen/typescript-react-query/@graphql-codegen/visitor-plugin-common/change-case-all/change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="], + + "@graphql-codegen/typescript-react-query/@graphql-codegen/visitor-plugin-common/change-case-all/sponge-case": ["sponge-case@2.0.3", "", {}, "sha512-i4h9ZGRfxV6Xw3mpZSFOfbXjf0cQcYmssGWutgNIfFZ2VM+YIWfD71N/kjjwK6X/AAHzBr+rciEcn/L34S8TGw=="], + + "@graphql-codegen/typescript-react-query/@graphql-codegen/visitor-plugin-common/change-case-all/swap-case": ["swap-case@3.0.3", "", {}, "sha512-6p4op8wE9CQv7uDFzulI6YXUw4lD9n4oQierdbFThEKVWVQcbQcUjdP27W8XE7V4QnWmnq9jueSHceyyQnqQVA=="], "@graphql-codegen/typescript/@graphql-codegen/visitor-plugin-common/@graphql-tools/optimize/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -4219,6 +4245,10 @@ "graphql-config/@graphql-tools/url-loader/@graphql-tools/wrap/@graphql-tools/delegate": ["@graphql-tools/delegate@10.2.23", "", { "dependencies": { "@graphql-tools/batch-execute": "^9.0.19", "@graphql-tools/executor": "^1.4.9", "@graphql-tools/schema": "^10.0.25", "@graphql-tools/utils": "^10.9.1", "@repeaterjs/repeater": "^3.0.6", "@whatwg-node/promise-helpers": "^1.3.0", "dataloader": "^2.2.3", "dset": "^3.1.2", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-xrPtl7f1LxS+B6o+W7ueuQh67CwRkfl+UKJncaslnqYdkxKmNBB4wnzVcW8ZsRdwbsla/v43PtwAvSlzxCzq2w=="], + "@graphql-codegen/typescript-react-query/@graphql-codegen/visitor-plugin-common/@graphql-tools/relay-operation-optimizer/@ardatan/relay-compiler/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@graphql-codegen/typescript-react-query/@graphql-codegen/visitor-plugin-common/@graphql-tools/relay-operation-optimizer/@ardatan/relay-compiler/immutable": ["immutable@5.1.9", "", {}, "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg=="], + "@unlighthouse/core/lighthouse/configstore/dot-prop/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "graphql-config/@graphql-tools/url-loader/@graphql-tools/wrap/@graphql-tools/delegate/@graphql-tools/batch-execute": ["@graphql-tools/batch-execute@9.0.19", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "@whatwg-node/promise-helpers": "^1.3.0", "dataloader": "^2.2.3", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-VGamgY4PLzSx48IHPoblRw0oTaBa7S26RpZXt0Y4NN90ytoE0LutlpB2484RbkfcTjv9wa64QD474+YP1kEgGA=="], diff --git a/package.json b/package.json index e23fdf7..15dade7 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/components/organizations/CreateOrganizationButton.tsx b/src/components/organizations/CreateOrganizationButton.tsx new file mode 100644 index 0000000..1e381b2 --- /dev/null +++ b/src/components/organizations/CreateOrganizationButton.tsx @@ -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("unknown"); + const [error, setError] = useState(null); + + const slug = name.trim() ? generateSlug(name.trim()) : ""; + + const { create, isCreating } = useCreateWorkspace({ + 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 ( + <> + + + {open && ( +
+
+
+

Create organization

+ +
+

+ An organization is where your projects and workspaces live. +

+ +
+ setName(e.target.value)} + placeholder="Organization name" + autoComplete="off" + className="h-9 rounded-md border bg-background px-3 text-sm" + /> + + {slug && ( +

+ URL: {slug} +

+ )} + + {slug.length >= MIN_HANDLE_LENGTH && + availability === "available" && ( +

Available

+ )} + + {slug.length >= MIN_HANDLE_LENGTH && availability === "taken" && ( +

+ That handle is already taken. Handles are unique across Omni. +

+ )} + + {error &&

{error}

} + +

+ Your organization is part of your Omni account and works across + Omni products. +

+ +
+ + + +
+
+
+
+ )} + + ); +}; + +export default CreateOrganizationButton; diff --git a/src/routes/_auth/organizations/$orgSlug.tsx b/src/routes/_auth/organizations/$orgSlug.tsx index b762b58..09802cf 100644 --- a/src/routes/_auth/organizations/$orgSlug.tsx +++ b/src/routes/_auth/organizations/$orgSlug.tsx @@ -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, @@ -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 ( @@ -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 ; } diff --git a/src/routes/_auth/organizations/index.tsx b/src/routes/_auth/organizations/index.tsx index 02fc217..39b1bc5 100644 --- a/src/routes/_auth/organizations/index.tsx +++ b/src/routes/_auth/organizations/index.tsx @@ -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/")({ @@ -15,7 +16,11 @@ function OrganizationsPage() { return (
-

Organizations

+
+

Organizations

+ + {organizations.length > 0 && } +
{organizations.map((org) => ( @@ -46,10 +51,12 @@ function OrganizationsPage() {
{organizations.length === 0 && ( -

- No organizations found. You should have at least a personal - organization. -

+
+

+ No organizations yet. Create one to get started. +

+ +
)}
); diff --git a/src/server/functions/authorization.ts b/src/server/functions/authorization.ts index 39fa642..e5333de 100644 --- a/src/server/functions/authorization.ts +++ b/src/server/functions/authorization.ts @@ -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((v) => typeof v === "string"); +const permission = z.custom>( + (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, }), ), }); @@ -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); }); diff --git a/src/server/functions/organizations.ts b/src/server/functions/organizations.ts index 0122bae..ae4d0ac 100644 --- a/src/server/functions/organizations.ts +++ b/src/server/functions/organizations.ts @@ -47,7 +47,6 @@ const removeOrganizationMemberSchema = z.object({ /** * Create a new organization via Gatekeeper. - * @knipignore */ export const createOrganization = createServerFn({ method: "POST" }) .inputValidator((data) => createOrganizationSchema.parse(data)) @@ -62,6 +61,23 @@ export const createOrganization = createServerFn({ method: "POST" }) return gatekeeperOrg.createOrganization(data, accessToken); }); +const checkOrganizationHandleSchema = z.object({ + slug: z.string().min(1), +}); + +/** + * Check whether an organization handle (slug) is available across the ecosystem + * namespace. Backs live validation in the create-organization form. Public + * check, so no auth middleware + */ +export const checkOrganizationHandleAvailability = createServerFn({ + method: "GET", +}) + .inputValidator((data) => checkOrganizationHandleSchema.parse(data)) + .handler(async ({ data }) => { + return gatekeeperOrg.checkNamespaceAvailability(data.slug); + }); + /** * Get an organization by slug. * Used when JWT claims are stale and don't include a newly created org