From 69b8cf1395a10aeabdda2d0617d8be7d09fd868c Mon Sep 17 00:00:00 2001 From: NasSharaf Date: Tue, 6 May 2025 16:42:13 -0400 Subject: [PATCH] Added project pages and cards, most of the screens in chris samuels figma design document. Still need to implement project initialization modal and walkthrough, connect to backend and connect to wallet (maybe be beyond scope of this project) --- .cursor/rules/check-docs.mdc | 11 + .cursor/rules/nextjs-filetype-scrutiny.mdc | 12 + .cursor/rules/ui-components-in-workspace.mdc | 10 + .github/CODEOWNERS | 30 + .github/CONTRIBUTING.md | 46 + .gitignore | 75 + .npmrc | 3 + .vscode/extensions.json | 3 + .vscode/settings.json | 38 + LICENSE | 21 + apps/.gitignore | 41 + apps/backend/README.md | 76 + apps/backend/biome.json | 32 + apps/backend/environments/local.toml | 43 + apps/backend/environments/local.toml.example | 43 + apps/backend/package.json | 68 + apps/backend/src/config.ts | 66 + apps/backend/src/constants.ts | 7 + apps/backend/src/database.ts | 694 + apps/backend/src/entity/Deployer.ts | 32 + apps/backend/src/entity/Deployment.ts | 159 + apps/backend/src/entity/Domain.ts | 59 + .../backend/src/entity/EnvironmentVariable.ts | 44 + apps/backend/src/entity/Organization.ts | 38 + apps/backend/src/entity/Project.ts | 111 + apps/backend/src/entity/ProjectMember.ts | 57 + apps/backend/src/entity/User.ts | 65 + apps/backend/src/entity/UserOrganization.ts | 47 + apps/backend/src/index.ts | 53 + apps/backend/src/registry.ts | 624 + apps/backend/src/resolvers.ts | 413 + apps/backend/src/routes/auth.ts | 97 + apps/backend/src/routes/github.ts | 26 + apps/backend/src/routes/staging.ts | 9 + apps/backend/src/schema.gql | 337 + apps/backend/src/server.ts | 130 + apps/backend/src/service.ts | 1783 +++ apps/backend/src/turnkey-backend.ts | 130 + apps/backend/src/types.ts | 124 + apps/backend/src/utils.ts | 160 + apps/backend/test/delete-db.ts | 19 + apps/backend/test/fixtures/deployments.json | 189 + .../test/fixtures/environment-variables.json | 92 + apps/backend/test/fixtures/organizations.json | 7 + .../test/fixtures/primary-domains.json | 44 + .../test/fixtures/project-members.json | 56 + apps/backend/test/fixtures/projects.json | 67 + .../test/fixtures/redirected-domains.json | 23 + .../test/fixtures/user-organizations.json | 22 + apps/backend/test/fixtures/users.json | 23 + apps/backend/test/initialize-db.ts | 176 + apps/backend/test/initialize-registry.ts | 49 + apps/backend/test/publish-deploy-records.ts | 100 + .../publish-deployment-removal-records.ts | 70 + apps/backend/tsconfig.json | 13 + apps/deploy-fe/.env.example | 6 + apps/deploy-fe/.gitignore | 12 + apps/deploy-fe/.vscode/settings.json | 45 + apps/deploy-fe/biome.jsonc | 3 + apps/deploy-fe/components.json | 20 + apps/deploy-fe/next.config.mjs | 16 + apps/deploy-fe/package.json | 90 + apps/deploy-fe/postcss.config.mjs | 1 + apps/deploy-fe/repo_structure.txt | 220 + apps/deploy-fe/src/actions/github.ts | 36 + .../DocumentationPlaceholder.tsx | 669 + .../(dashboard)/documentation/page.tsx | 12 + .../(dashboard)/home/loading.tsx | 6 + .../(dashboard)/home/page.tsx | 191 + .../(dashboard)/layout.tsx | 19 + .../ps/(create)/cr/(configure)/cf/page.tsx | 227 + .../ps/(create)/cr/(deploy)/dp/page.tsx | 270 + .../ps/(create)/cr/(success)/sc/[id]/page.tsx | 258 + .../cr/(template)/tm/(configure)/cf/page.tsx | 12 + .../cr/(template)/tm/(deploy)/dp/page.tsx | 16 + .../ps/[id]/(deployments)/dep/page.tsx | 185 + .../ps/[id]/(integrations)/int/GitPage.tsx | 198 + .../ps/[id]/(integrations)/int/page.tsx | 82 + .../set/(collaborators)/col/page.tsx | 66 + .../set/(domains)/dom/(add)/cf/page.tsx | 12 + .../(domains)/dom/(add)/config/cf/page.tsx | 12 + .../env/EnvVarsPage.tsx | 415 + .../set/(environment-variables)/env/page.tsx | 83 + .../ps/[id]/(settings)/set/(git)/page.tsx | 82 + .../(settings)/set/ProjectSettingsPage.tsx | 274 + .../ps/[id]/(settings)/set/page.tsx | 84 + .../[provider]/ps/[id]/deployments/page.tsx | 159 + .../projects/[provider]/ps/[id]/layout.tsx | 14 + .../projects/[provider]/ps/[id]/loading.tsx | 14 + .../projects/[provider]/ps/[id]/page.tsx | 267 + .../(dashboard)/projects/error.tsx | 30 + .../(dashboard)/projects/loading.tsx | 11 + .../(dashboard)/projects/page.tsx | 161 + .../(dashboard)/purchase/BuyServices.tsx | 142 + .../(dashboard)/purchase/page.tsx | 13 + .../(dashboard)/store/page.tsx | 19 + .../support/SupportPlaceholder.tsx | 255 + .../(dashboard)/support/page.tsx | 10 + .../(dashboard)/wallet/page.tsx | 19 + .../src/app/(web3-authenticated)/layout.tsx | 11 + apps/deploy-fe/src/app/actions/github.ts | 36 + apps/deploy-fe/src/app/api/auth/route.ts | 52 + .../src/app/api/github/webhook/route.ts | 66 + apps/deploy-fe/src/app/favicon.ico | Bin 0 -> 15406 bytes apps/deploy-fe/src/app/layout.tsx | 38 + apps/deploy-fe/src/app/loading.tsx | 5 + apps/deploy-fe/src/app/page.tsx | 87 + .../src/app/sign-in/[[...sign-in]]/page.tsx | 30 + .../src/components/assets/laconic-mark.tsx | 43 + .../src/components/core/dropdown/Dropdown.tsx | 54 + .../src/components/core/dropdown/README.md | 12 + .../src/components/core/dropdown/index.ts | 2 + .../src/components/core/dropdown/types.ts | 11 + .../format-milli-second/FormatMilliSecond.tsx | 31 + .../core/format-milli-second/README.md | 12 + .../core/format-milli-second/index.ts | 2 + .../core/format-milli-second/types.ts | 11 + .../src/components/core/logo/Logo.tsx | 26 + .../src/components/core/logo/README.md | 12 + .../src/components/core/logo/index.ts | 2 + .../src/components/core/logo/types.ts | 8 + .../src/components/core/search-bar/README.md | 12 + .../components/core/search-bar/SearchBar.tsx | 51 + .../src/components/core/search-bar/index.ts | 2 + .../src/components/core/search-bar/types.ts | 4 + .../src/components/core/stepper/README.md | 12 + .../src/components/core/stepper/Stepper.tsx | 48 + .../src/components/core/stepper/index.ts | 2 + .../src/components/core/stepper/types.ts | 23 + .../src/components/core/stop-watch/README.md | 12 + .../components/core/stop-watch/StopWatch.tsx | 65 + .../src/components/core/stop-watch/index.ts | 2 + .../src/components/core/stop-watch/types.ts | 12 + .../core/vertical-stepper/README.md | 12 + .../core/vertical-stepper/VerticalStepper.tsx | 103 + .../components/core/vertical-stepper/index.ts | 2 + .../components/core/vertical-stepper/types.ts | 47 + .../coming-soon-overlay/ComingSoonOverlay.tsx | 32 + .../foundation/coming-soon-overlay/index.ts | 1 + .../GitHubSessionButton.tsx | 9 + .../github-session-button/README.md | 12 + .../foundation/github-session-button/index.ts | 2 + .../foundation/github-session-button/types.ts | 3 + .../src/components/foundation/index.ts | 17 + .../foundation/laconic-icon/LaconicIcon.tsx | 28 + .../foundation/laconic-icon/README.md | 12 + .../foundation/laconic-icon/index.ts | 2 + .../foundation/laconic-icon/types.ts | 19 + .../loading-overlay/LoadingOverlay.tsx | 83 + .../loading/loading-overlay/README.md | 29 + .../loading/loading-overlay/index.ts | 1 + .../navigation-wrapper/NavigationWrapper.tsx | 225 + .../foundation/navigation-wrapper/README.md | 28 + .../foundation/navigation-wrapper/index.ts | 4 + .../foundation/page-header/PageHeader.tsx | 343 + .../foundation/page-header/README.md | 62 + .../foundation/page-header/index.ts | 5 + .../foundation/page-wrapper/PageWrapper.tsx | 273 + .../foundation/page-wrapper/README.md | 28 + .../foundation/page-wrapper/index.ts | 2 + .../project-search-bar/ProjectSearchBar.tsx | 185 + .../foundation/project-search-bar/README.md | 12 + .../foundation/project-search-bar/index.ts | 2 + .../foundation/project-search-bar/types.ts | 25 + .../foundation/top-navigation/README.md | 33 + .../dark-mode-toggle/DarkModeToggle.tsx | 40 + .../top-navigation/dark-mode-toggle/README.md | 22 + .../top-navigation/dark-mode-toggle/index.ts | 1 + .../foundation/top-navigation/index.ts | 5 + .../main-navigation/MainNavigation.tsx | 300 + .../top-navigation/main-navigation/README.md | 32 + .../top-navigation/main-navigation/index.ts | 1 + .../navigation-item/NavigationItem.tsx | 315 + .../top-navigation/navigation-item/README.md | 21 + .../top-navigation/navigation-item/index.ts | 1 + .../foundation/top-navigation/types.ts | 14 + .../wallet-session-badge/README.md | 21 + .../WalletSessionBadge.tsx | 175 + .../wallet-session-badge/index.ts | 1 + .../src/components/foundation/types.ts | 77 + .../foundation/wallet-session-id/README.md | 12 + .../wallet-session-id/WalletSessionId.tsx | 105 + .../foundation/wallet-session-id/index.ts | 2 + .../foundation/wallet-session-id/types.ts | 20 + .../auto-sign-in/AutoSignInIFrameModal.tsx | 182 + .../components/iframe/auto-sign-in/README.md | 37 + .../components/iframe/auto-sign-in/index.ts | 2 + .../components/iframe/auto-sign-in/types.ts | 6 + .../CheckBalanceIframe.tsx | 87 + .../CheckBalanceWrapper.tsx | 14 + .../check-balance-iframe/useCheckBalance.tsx | 78 + apps/deploy-fe/src/components/layout/index.ts | 1 + .../GitHubSessionButton.tsx | 133 + .../github-session-button/README.md | 25 + .../navigation/github-session-button/index.ts | 2 + .../navigation/github-session-button/types.ts | 4 + .../navigation/laconic-icon/LaconicIcon.tsx | 34 + .../layout/navigation/laconic-icon/README.md | 29 + .../layout/navigation/laconic-icon/index.ts | 2 + .../layout/navigation/laconic-icon/types.ts | 19 + .../navigation-actions/NavigationActions.tsx | 66 + .../navigation/navigation-actions/README.md | 28 + .../navigation/navigation-actions/index.ts | 2 + .../navigation/navigation-actions/types.ts | 4 + .../navigation/wallet-session-id/README.md | 29 + .../wallet-session-id/WalletSessionId.tsx | 30 + .../navigation/wallet-session-id/index.ts | 2 + .../navigation/wallet-session-id/types.ts | 13 + .../components/loading/loading-overlay.tsx | 83 + .../src/components/onboarding/OPTIMIZATION.md | 117 + .../src/components/onboarding/Onboarding.tsx | 54 + .../onboarding/OnboardingButton.tsx | 11 + .../onboarding/OnboardingDialog.tsx | 241 + .../src/components/onboarding/README.md | 109 + .../onboarding/common/background-svg.tsx | 21 + .../src/components/onboarding/common/index.ts | 13 + .../common/laconic-icon-lettering.tsx | 63 + .../common/onboarding-container.tsx | 35 + .../onboarding/common/step-header.tsx | 45 + .../onboarding/common/step-navigation.tsx | 80 + .../configure-step/configure-step.tsx | 68 + .../onboarding/configure-step/index.ts | 9 + .../connect-step/connect-button.tsx | 51 + .../connect-step/connect-deploy-first-app.tsx | 41 + .../connect-step/connect-initial.tsx | 70 + .../onboarding/connect-step/connect-step.tsx | 48 + .../onboarding/connect-step/index.ts | 14 + .../connect-step/repository-list.tsx | 46 + .../onboarding/connect-step/template-list.tsx | 62 + .../onboarding/deploy-step/deploy-step.tsx | 42 + .../onboarding/deploy-step/index.ts | 9 + .../src/components/onboarding/index.ts | 33 + .../components/onboarding/sidebar/index.ts | 9 + .../onboarding/sidebar/sidebar-nav.tsx | 135 + .../src/components/onboarding/store.ts | 70 + .../src/components/onboarding/types.ts | 89 + .../components/onboarding/useOnboarding.ts | 42 + .../project/ProjectCard/FixedProjectCard.tsx | 231 + .../project/ProjectCard/ProjectCard.tsx | 135 + .../ProjectCard/ProjectCardActions.tsx | 73 + .../ProjectCard/ProjectDeploymentInfo.tsx | 71 + .../project/ProjectCard/ProjectStatusDot.tsx | 56 + .../projects/project/ProjectCard/index.ts | 1 + .../ProjectSearchBar/ProjectSearchBar.tsx | 94 + .../ProjectSearchBarDialog.tsx | 138 + .../ProjectSearchBarEmpty.tsx | 24 + .../ProjectSearchBar/ProjectSearchBarItem.tsx | 62 + .../project/ProjectSearchBar/index.ts | 2 + .../deployments/DeploymentDetailsCard.tsx | 130 + .../project/deployments/FilterForm.tsx | 97 + .../project/overview/Activity/AuctionCard.tsx | 25 + .../project/overview/OverviewInfo.tsx | 21 + apps/deploy-fe/src/components/providers.tsx | 62 + .../src/context/GQLClientContext.tsx | 41 + apps/deploy-fe/src/context/OctokitContext.tsx | 156 + .../src/context/OctokitProviderWithRouter.tsx | 18 + apps/deploy-fe/src/context/WalletContext.tsx | 183 + .../src/context/WalletContextProvider.tsx | 246 + apps/deploy-fe/src/context/index.ts | 4 + .../src/hooks/disabled_useRepoData.tsx | 169 + apps/deploy-fe/src/hooks/useDeployment.tsx | 94 + apps/deploy-fe/src/hooks/useRepoData.tsx | 145 + apps/deploy-fe/src/hooks/useRepoSelection.tsx | 160 + apps/deploy-fe/src/lib/utils.ts | 6 + apps/deploy-fe/src/middleware.ts | 53 + apps/deploy-fe/src/types/common.ts | 27 + apps/deploy-fe/src/types/dashboard.ts | 65 + apps/deploy-fe/src/types/deployment.ts | 21 + apps/deploy-fe/src/types/hooks/.gitkeep | 0 apps/deploy-fe/src/types/hooks/use-mobile.tsx | 19 + apps/deploy-fe/src/types/index.ts | 2 + apps/deploy-fe/src/types/project.ts | 20 + apps/deploy-fe/src/utils/getInitials.ts | 8 + apps/deploy-fe/src/utils/time.ts | 7 + .../standards/architecture/routes.md | 94 + apps/deploy-fe/tailwind.config.ts | 1 + apps/deploy-fe/tsconfig.json | 24 + apps/deployer/README.md | 64 + apps/deployer/biome.json | 6 + apps/deployer/config.staging.yml | 10 + apps/deployer/config.yml | 8 + apps/deployer/deploy-frontend.sh | 155 + apps/deployer/deploy-frontend.vaasl.sh | 148 + apps/deployer/package.json | 14 + apps/deployer/records/.gitkeep | 0 apps/deployer/remove-deployment.sh | 56 + apps/deployer/test/README.md | 23 + ...application-deployment-removal-request.yml | 4 + .../application-deployment-request.yml | 15 + .../test/records/application-record.yml | 8 + .../test-webapp-deployment-undeployment.sh | 225 + biome.json | 59 + .../0-wallet-integration-overview.md | 92 + .../wallet_migration/1-phase-1-wallet-core.md | 583 + .../wallet_migration/2-phase-2-wallet-ui.md | 592 + .../3-phase-3-clerk-integration.md | 754 + lefthook.yaml | 23 + next-agent-01.md | 180 + package.json | 38 + pnpm-lock.yaml | 11385 ++++++++++++++++ pnpm-workspace.yaml | 30 + scripts/README.md | 36 + scripts/clean.sh | 63 + scripts/folderize-components.sh | 33 + scripts/hooks-logger.js | 70 + scripts/setup-component-structure.js | 134 + services/.gitignore | 4 + services/gql-client/biome.json | 7 + services/gql-client/package.json | 23 + services/gql-client/src/client.ts | 473 + services/gql-client/src/index.ts | 2 + services/gql-client/src/mutations.ts | 127 + services/gql-client/src/queries.ts | 339 + services/gql-client/src/types.ts | 403 + services/gql-client/tsconfig.json | 9 + services/gql-client/tsup.config.ts | 9 + services/typescript-config/README.md | 3 + services/typescript-config/base.json | 20 + services/typescript-config/nextjs.json | 13 + services/typescript-config/package.json | 9 + services/typescript-config/react-library.json | 8 + services/ui/.gitignore | 4 + services/ui/components.json | 20 + services/ui/package.json | 81 + services/ui/postcss.config.mjs | 10 + services/ui/src/components/.gitkeep | 0 services/ui/src/components/accordion.tsx | 66 + services/ui/src/components/alert-dialog.tsx | 157 + services/ui/src/components/alert.tsx | 66 + services/ui/src/components/aspect-ratio.tsx | 11 + services/ui/src/components/avatar.tsx | 53 + services/ui/src/components/badge.tsx | 46 + services/ui/src/components/breadcrumb.tsx | 106 + services/ui/src/components/button.tsx | 107 + services/ui/src/components/calendar.tsx | 76 + services/ui/src/components/card.tsx | 68 + services/ui/src/components/carousel.tsx | 241 + services/ui/src/components/chart.tsx | 353 + services/ui/src/components/checkbox.tsx | 32 + services/ui/src/components/collapsible.tsx | 33 + services/ui/src/components/command.tsx | 177 + services/ui/src/components/context-menu.tsx | 252 + services/ui/src/components/dialog.tsx | 135 + services/ui/src/components/drawer.tsx | 133 + services/ui/src/components/dropdown-menu.tsx | 257 + services/ui/src/components/form.tsx | 167 + services/ui/src/components/hover-card.tsx | 42 + services/ui/src/components/input-otp.tsx | 73 + services/ui/src/components/input.tsx | 21 + services/ui/src/components/label.tsx | 24 + services/ui/src/components/menubar.tsx | 274 + .../ui/src/components/navigation-menu.tsx | 170 + services/ui/src/components/pagination.tsx | 126 + services/ui/src/components/popover.tsx | 48 + services/ui/src/components/progress.tsx | 31 + services/ui/src/components/radio-group.tsx | 45 + services/ui/src/components/resizable.tsx | 56 + services/ui/src/components/scroll-area.tsx | 58 + services/ui/src/components/select.tsx | 181 + services/ui/src/components/separator.tsx | 28 + services/ui/src/components/sheet.tsx | 142 + services/ui/src/components/sidebar.tsx | 721 + services/ui/src/components/skeleton.tsx | 13 + services/ui/src/components/slider.tsx | 63 + services/ui/src/components/sonner.tsx | 29 + services/ui/src/components/switch.tsx | 31 + services/ui/src/components/table.tsx | 116 + services/ui/src/components/tabs.tsx | 66 + services/ui/src/components/textarea.tsx | 18 + services/ui/src/components/toggle-group.tsx | 73 + services/ui/src/components/toggle.tsx | 47 + services/ui/src/components/tooltip.tsx | 61 + services/ui/src/hooks/.gitkeep | 0 services/ui/src/hooks/use-mobile.ts | 19 + services/ui/src/lib/utils.ts | 6 + services/ui/src/styles/globals.css | 68 + services/ui/tailwind.config.ts | 61 + services/ui/tsconfig.json | 12 + services/ui/tsconfig.lint.json | 8 + standards/blueprints/file-migration-list.md | 257 + .../blueprints/next-app-router-structure.md | 171 + standards/blueprints/nextjs-templates.md | 441 + .../qwrk-laconic-migration-guide.md | 1173 ++ standards/current-tech-reference.md | 215 + .../documentation/COMPONENT_DOCUMENTATION.md | 525 + standards/documentation/FEATURE_BUILDING.md | 232 + .../FEATURE_BUILDING_TEMPLATE.md | 239 + standards/documentation/README.md | 89 + .../react-component-conventions.md | 128 + tsconfig.base.json | 14 + tsconfig.json | 3 + turbo.json | 47 + 392 files changed, 45602 insertions(+) create mode 100644 .cursor/rules/check-docs.mdc create mode 100644 .cursor/rules/nextjs-filetype-scrutiny.mdc create mode 100644 .cursor/rules/ui-components-in-workspace.mdc create mode 100644 .github/CODEOWNERS create mode 100644 .github/CONTRIBUTING.md create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 apps/.gitignore create mode 100644 apps/backend/README.md create mode 100644 apps/backend/biome.json create mode 100644 apps/backend/environments/local.toml create mode 100644 apps/backend/environments/local.toml.example create mode 100644 apps/backend/package.json create mode 100644 apps/backend/src/config.ts create mode 100644 apps/backend/src/constants.ts create mode 100644 apps/backend/src/database.ts create mode 100644 apps/backend/src/entity/Deployer.ts create mode 100644 apps/backend/src/entity/Deployment.ts create mode 100644 apps/backend/src/entity/Domain.ts create mode 100644 apps/backend/src/entity/EnvironmentVariable.ts create mode 100644 apps/backend/src/entity/Organization.ts create mode 100644 apps/backend/src/entity/Project.ts create mode 100644 apps/backend/src/entity/ProjectMember.ts create mode 100644 apps/backend/src/entity/User.ts create mode 100644 apps/backend/src/entity/UserOrganization.ts create mode 100644 apps/backend/src/index.ts create mode 100644 apps/backend/src/registry.ts create mode 100644 apps/backend/src/resolvers.ts create mode 100644 apps/backend/src/routes/auth.ts create mode 100644 apps/backend/src/routes/github.ts create mode 100644 apps/backend/src/routes/staging.ts create mode 100644 apps/backend/src/schema.gql create mode 100644 apps/backend/src/server.ts create mode 100644 apps/backend/src/service.ts create mode 100644 apps/backend/src/turnkey-backend.ts create mode 100644 apps/backend/src/types.ts create mode 100644 apps/backend/src/utils.ts create mode 100644 apps/backend/test/delete-db.ts create mode 100644 apps/backend/test/fixtures/deployments.json create mode 100644 apps/backend/test/fixtures/environment-variables.json create mode 100644 apps/backend/test/fixtures/organizations.json create mode 100644 apps/backend/test/fixtures/primary-domains.json create mode 100644 apps/backend/test/fixtures/project-members.json create mode 100644 apps/backend/test/fixtures/projects.json create mode 100644 apps/backend/test/fixtures/redirected-domains.json create mode 100644 apps/backend/test/fixtures/user-organizations.json create mode 100644 apps/backend/test/fixtures/users.json create mode 100644 apps/backend/test/initialize-db.ts create mode 100644 apps/backend/test/initialize-registry.ts create mode 100644 apps/backend/test/publish-deploy-records.ts create mode 100644 apps/backend/test/publish-deployment-removal-records.ts create mode 100644 apps/backend/tsconfig.json create mode 100644 apps/deploy-fe/.env.example create mode 100644 apps/deploy-fe/.gitignore create mode 100644 apps/deploy-fe/.vscode/settings.json create mode 100644 apps/deploy-fe/biome.jsonc create mode 100644 apps/deploy-fe/components.json create mode 100644 apps/deploy-fe/next.config.mjs create mode 100644 apps/deploy-fe/package.json create mode 100644 apps/deploy-fe/postcss.config.mjs create mode 100644 apps/deploy-fe/repo_structure.txt create mode 100644 apps/deploy-fe/src/actions/github.ts create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/documentation/DocumentationPlaceholder.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/documentation/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/home/loading.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/home/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/layout.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(configure)/cf/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(deploy)/dp/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(success)/sc/[id]/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(template)/tm/(configure)/cf/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(template)/tm/(deploy)/dp/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(deployments)/dep/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(integrations)/int/GitPage.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(integrations)/int/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(collaborators)/col/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(domains)/dom/(add)/cf/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(domains)/dom/(add)/config/cf/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(environment-variables)/env/EnvVarsPage.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(environment-variables)/env/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(git)/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/ProjectSettingsPage.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/deployments/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/layout.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/loading.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/error.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/loading.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/purchase/BuyServices.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/purchase/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/store/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/support/SupportPlaceholder.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/support/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/wallet/page.tsx create mode 100644 apps/deploy-fe/src/app/(web3-authenticated)/layout.tsx create mode 100644 apps/deploy-fe/src/app/actions/github.ts create mode 100644 apps/deploy-fe/src/app/api/auth/route.ts create mode 100644 apps/deploy-fe/src/app/api/github/webhook/route.ts create mode 100644 apps/deploy-fe/src/app/favicon.ico create mode 100644 apps/deploy-fe/src/app/layout.tsx create mode 100644 apps/deploy-fe/src/app/loading.tsx create mode 100644 apps/deploy-fe/src/app/page.tsx create mode 100644 apps/deploy-fe/src/app/sign-in/[[...sign-in]]/page.tsx create mode 100644 apps/deploy-fe/src/components/assets/laconic-mark.tsx create mode 100644 apps/deploy-fe/src/components/core/dropdown/Dropdown.tsx create mode 100644 apps/deploy-fe/src/components/core/dropdown/README.md create mode 100644 apps/deploy-fe/src/components/core/dropdown/index.ts create mode 100644 apps/deploy-fe/src/components/core/dropdown/types.ts create mode 100644 apps/deploy-fe/src/components/core/format-milli-second/FormatMilliSecond.tsx create mode 100644 apps/deploy-fe/src/components/core/format-milli-second/README.md create mode 100644 apps/deploy-fe/src/components/core/format-milli-second/index.ts create mode 100644 apps/deploy-fe/src/components/core/format-milli-second/types.ts create mode 100644 apps/deploy-fe/src/components/core/logo/Logo.tsx create mode 100644 apps/deploy-fe/src/components/core/logo/README.md create mode 100644 apps/deploy-fe/src/components/core/logo/index.ts create mode 100644 apps/deploy-fe/src/components/core/logo/types.ts create mode 100644 apps/deploy-fe/src/components/core/search-bar/README.md create mode 100644 apps/deploy-fe/src/components/core/search-bar/SearchBar.tsx create mode 100644 apps/deploy-fe/src/components/core/search-bar/index.ts create mode 100644 apps/deploy-fe/src/components/core/search-bar/types.ts create mode 100644 apps/deploy-fe/src/components/core/stepper/README.md create mode 100644 apps/deploy-fe/src/components/core/stepper/Stepper.tsx create mode 100644 apps/deploy-fe/src/components/core/stepper/index.ts create mode 100644 apps/deploy-fe/src/components/core/stepper/types.ts create mode 100644 apps/deploy-fe/src/components/core/stop-watch/README.md create mode 100644 apps/deploy-fe/src/components/core/stop-watch/StopWatch.tsx create mode 100644 apps/deploy-fe/src/components/core/stop-watch/index.ts create mode 100644 apps/deploy-fe/src/components/core/stop-watch/types.ts create mode 100644 apps/deploy-fe/src/components/core/vertical-stepper/README.md create mode 100644 apps/deploy-fe/src/components/core/vertical-stepper/VerticalStepper.tsx create mode 100644 apps/deploy-fe/src/components/core/vertical-stepper/index.ts create mode 100644 apps/deploy-fe/src/components/core/vertical-stepper/types.ts create mode 100644 apps/deploy-fe/src/components/foundation/coming-soon-overlay/ComingSoonOverlay.tsx create mode 100644 apps/deploy-fe/src/components/foundation/coming-soon-overlay/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/github-session-button/GitHubSessionButton.tsx create mode 100644 apps/deploy-fe/src/components/foundation/github-session-button/README.md create mode 100644 apps/deploy-fe/src/components/foundation/github-session-button/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/github-session-button/types.ts create mode 100644 apps/deploy-fe/src/components/foundation/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/laconic-icon/LaconicIcon.tsx create mode 100644 apps/deploy-fe/src/components/foundation/laconic-icon/README.md create mode 100644 apps/deploy-fe/src/components/foundation/laconic-icon/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/laconic-icon/types.ts create mode 100644 apps/deploy-fe/src/components/foundation/loading/loading-overlay/LoadingOverlay.tsx create mode 100644 apps/deploy-fe/src/components/foundation/loading/loading-overlay/README.md create mode 100644 apps/deploy-fe/src/components/foundation/loading/loading-overlay/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/navigation-wrapper/NavigationWrapper.tsx create mode 100644 apps/deploy-fe/src/components/foundation/navigation-wrapper/README.md create mode 100644 apps/deploy-fe/src/components/foundation/navigation-wrapper/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/page-header/PageHeader.tsx create mode 100644 apps/deploy-fe/src/components/foundation/page-header/README.md create mode 100644 apps/deploy-fe/src/components/foundation/page-header/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/page-wrapper/PageWrapper.tsx create mode 100644 apps/deploy-fe/src/components/foundation/page-wrapper/README.md create mode 100644 apps/deploy-fe/src/components/foundation/page-wrapper/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/project-search-bar/ProjectSearchBar.tsx create mode 100644 apps/deploy-fe/src/components/foundation/project-search-bar/README.md create mode 100644 apps/deploy-fe/src/components/foundation/project-search-bar/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/project-search-bar/types.ts create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/README.md create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/DarkModeToggle.tsx create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/README.md create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/MainNavigation.tsx create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/README.md create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/NavigationItem.tsx create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/README.md create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/types.ts create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/README.md create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/WalletSessionBadge.tsx create mode 100644 apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/types.ts create mode 100644 apps/deploy-fe/src/components/foundation/wallet-session-id/README.md create mode 100644 apps/deploy-fe/src/components/foundation/wallet-session-id/WalletSessionId.tsx create mode 100644 apps/deploy-fe/src/components/foundation/wallet-session-id/index.ts create mode 100644 apps/deploy-fe/src/components/foundation/wallet-session-id/types.ts create mode 100644 apps/deploy-fe/src/components/iframe/auto-sign-in/AutoSignInIFrameModal.tsx create mode 100644 apps/deploy-fe/src/components/iframe/auto-sign-in/README.md create mode 100644 apps/deploy-fe/src/components/iframe/auto-sign-in/index.ts create mode 100644 apps/deploy-fe/src/components/iframe/auto-sign-in/types.ts create mode 100644 apps/deploy-fe/src/components/iframe/check-balance-iframe/CheckBalanceIframe.tsx create mode 100644 apps/deploy-fe/src/components/iframe/check-balance-iframe/CheckBalanceWrapper.tsx create mode 100644 apps/deploy-fe/src/components/iframe/check-balance-iframe/useCheckBalance.tsx create mode 100644 apps/deploy-fe/src/components/layout/index.ts create mode 100644 apps/deploy-fe/src/components/layout/navigation/github-session-button/GitHubSessionButton.tsx create mode 100644 apps/deploy-fe/src/components/layout/navigation/github-session-button/README.md create mode 100644 apps/deploy-fe/src/components/layout/navigation/github-session-button/index.ts create mode 100644 apps/deploy-fe/src/components/layout/navigation/github-session-button/types.ts create mode 100644 apps/deploy-fe/src/components/layout/navigation/laconic-icon/LaconicIcon.tsx create mode 100644 apps/deploy-fe/src/components/layout/navigation/laconic-icon/README.md create mode 100644 apps/deploy-fe/src/components/layout/navigation/laconic-icon/index.ts create mode 100644 apps/deploy-fe/src/components/layout/navigation/laconic-icon/types.ts create mode 100644 apps/deploy-fe/src/components/layout/navigation/navigation-actions/NavigationActions.tsx create mode 100644 apps/deploy-fe/src/components/layout/navigation/navigation-actions/README.md create mode 100644 apps/deploy-fe/src/components/layout/navigation/navigation-actions/index.ts create mode 100644 apps/deploy-fe/src/components/layout/navigation/navigation-actions/types.ts create mode 100644 apps/deploy-fe/src/components/layout/navigation/wallet-session-id/README.md create mode 100644 apps/deploy-fe/src/components/layout/navigation/wallet-session-id/WalletSessionId.tsx create mode 100644 apps/deploy-fe/src/components/layout/navigation/wallet-session-id/index.ts create mode 100644 apps/deploy-fe/src/components/layout/navigation/wallet-session-id/types.ts create mode 100644 apps/deploy-fe/src/components/loading/loading-overlay.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/OPTIMIZATION.md create mode 100644 apps/deploy-fe/src/components/onboarding/Onboarding.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/OnboardingButton.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/OnboardingDialog.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/README.md create mode 100644 apps/deploy-fe/src/components/onboarding/common/background-svg.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/common/index.ts create mode 100644 apps/deploy-fe/src/components/onboarding/common/laconic-icon-lettering.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/common/onboarding-container.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/common/step-header.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/common/step-navigation.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/configure-step/configure-step.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/configure-step/index.ts create mode 100644 apps/deploy-fe/src/components/onboarding/connect-step/connect-button.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/connect-step/connect-deploy-first-app.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/connect-step/connect-initial.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/connect-step/connect-step.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/connect-step/index.ts create mode 100644 apps/deploy-fe/src/components/onboarding/connect-step/repository-list.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/connect-step/template-list.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/deploy-step/deploy-step.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/deploy-step/index.ts create mode 100644 apps/deploy-fe/src/components/onboarding/index.ts create mode 100644 apps/deploy-fe/src/components/onboarding/sidebar/index.ts create mode 100644 apps/deploy-fe/src/components/onboarding/sidebar/sidebar-nav.tsx create mode 100644 apps/deploy-fe/src/components/onboarding/store.ts create mode 100644 apps/deploy-fe/src/components/onboarding/types.ts create mode 100644 apps/deploy-fe/src/components/onboarding/useOnboarding.ts create mode 100644 apps/deploy-fe/src/components/projects/project/ProjectCard/FixedProjectCard.tsx create mode 100644 apps/deploy-fe/src/components/projects/project/ProjectCard/ProjectCard.tsx create mode 100644 apps/deploy-fe/src/components/projects/project/ProjectCard/ProjectCardActions.tsx create mode 100644 apps/deploy-fe/src/components/projects/project/ProjectCard/ProjectDeploymentInfo.tsx create mode 100644 apps/deploy-fe/src/components/projects/project/ProjectCard/ProjectStatusDot.tsx create mode 100644 apps/deploy-fe/src/components/projects/project/ProjectCard/index.ts create mode 100644 apps/deploy-fe/src/components/projects/project/ProjectSearchBar/ProjectSearchBar.tsx create mode 100644 apps/deploy-fe/src/components/projects/project/ProjectSearchBar/ProjectSearchBarDialog.tsx create mode 100644 apps/deploy-fe/src/components/projects/project/ProjectSearchBar/ProjectSearchBarEmpty.tsx create mode 100644 apps/deploy-fe/src/components/projects/project/ProjectSearchBar/ProjectSearchBarItem.tsx create mode 100644 apps/deploy-fe/src/components/projects/project/ProjectSearchBar/index.ts create mode 100644 apps/deploy-fe/src/components/projects/project/deployments/DeploymentDetailsCard.tsx create mode 100644 apps/deploy-fe/src/components/projects/project/deployments/FilterForm.tsx create mode 100644 apps/deploy-fe/src/components/projects/project/overview/Activity/AuctionCard.tsx create mode 100644 apps/deploy-fe/src/components/projects/project/overview/OverviewInfo.tsx create mode 100644 apps/deploy-fe/src/components/providers.tsx create mode 100644 apps/deploy-fe/src/context/GQLClientContext.tsx create mode 100644 apps/deploy-fe/src/context/OctokitContext.tsx create mode 100644 apps/deploy-fe/src/context/OctokitProviderWithRouter.tsx create mode 100644 apps/deploy-fe/src/context/WalletContext.tsx create mode 100644 apps/deploy-fe/src/context/WalletContextProvider.tsx create mode 100644 apps/deploy-fe/src/context/index.ts create mode 100644 apps/deploy-fe/src/hooks/disabled_useRepoData.tsx create mode 100644 apps/deploy-fe/src/hooks/useDeployment.tsx create mode 100644 apps/deploy-fe/src/hooks/useRepoData.tsx create mode 100644 apps/deploy-fe/src/hooks/useRepoSelection.tsx create mode 100644 apps/deploy-fe/src/lib/utils.ts create mode 100644 apps/deploy-fe/src/middleware.ts create mode 100644 apps/deploy-fe/src/types/common.ts create mode 100644 apps/deploy-fe/src/types/dashboard.ts create mode 100644 apps/deploy-fe/src/types/deployment.ts create mode 100644 apps/deploy-fe/src/types/hooks/.gitkeep create mode 100644 apps/deploy-fe/src/types/hooks/use-mobile.tsx create mode 100644 apps/deploy-fe/src/types/index.ts create mode 100644 apps/deploy-fe/src/types/project.ts create mode 100644 apps/deploy-fe/src/utils/getInitials.ts create mode 100644 apps/deploy-fe/src/utils/time.ts create mode 100644 apps/deploy-fe/standards/architecture/routes.md create mode 100644 apps/deploy-fe/tailwind.config.ts create mode 100644 apps/deploy-fe/tsconfig.json create mode 100644 apps/deployer/README.md create mode 100644 apps/deployer/biome.json create mode 100644 apps/deployer/config.staging.yml create mode 100644 apps/deployer/config.yml create mode 100755 apps/deployer/deploy-frontend.sh create mode 100755 apps/deployer/deploy-frontend.vaasl.sh create mode 100644 apps/deployer/package.json create mode 100644 apps/deployer/records/.gitkeep create mode 100755 apps/deployer/remove-deployment.sh create mode 100644 apps/deployer/test/README.md create mode 100644 apps/deployer/test/records/application-deployment-removal-request.yml create mode 100644 apps/deployer/test/records/application-deployment-request.yml create mode 100644 apps/deployer/test/records/application-record.yml create mode 100755 apps/deployer/test/test-webapp-deployment-undeployment.sh create mode 100644 biome.json create mode 100644 docs/architecture/wallet_migration/0-wallet-integration-overview.md create mode 100644 docs/architecture/wallet_migration/1-phase-1-wallet-core.md create mode 100644 docs/architecture/wallet_migration/2-phase-2-wallet-ui.md create mode 100644 docs/architecture/wallet_migration/3-phase-3-clerk-integration.md create mode 100644 lefthook.yaml create mode 100644 next-agent-01.md create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/README.md create mode 100644 scripts/clean.sh create mode 100755 scripts/folderize-components.sh create mode 100755 scripts/hooks-logger.js create mode 100755 scripts/setup-component-structure.js create mode 100644 services/.gitignore create mode 100644 services/gql-client/biome.json create mode 100644 services/gql-client/package.json create mode 100644 services/gql-client/src/client.ts create mode 100644 services/gql-client/src/index.ts create mode 100644 services/gql-client/src/mutations.ts create mode 100644 services/gql-client/src/queries.ts create mode 100644 services/gql-client/src/types.ts create mode 100644 services/gql-client/tsconfig.json create mode 100644 services/gql-client/tsup.config.ts create mode 100644 services/typescript-config/README.md create mode 100644 services/typescript-config/base.json create mode 100644 services/typescript-config/nextjs.json create mode 100644 services/typescript-config/package.json create mode 100644 services/typescript-config/react-library.json create mode 100644 services/ui/.gitignore create mode 100644 services/ui/components.json create mode 100644 services/ui/package.json create mode 100644 services/ui/postcss.config.mjs create mode 100644 services/ui/src/components/.gitkeep create mode 100644 services/ui/src/components/accordion.tsx create mode 100644 services/ui/src/components/alert-dialog.tsx create mode 100644 services/ui/src/components/alert.tsx create mode 100644 services/ui/src/components/aspect-ratio.tsx create mode 100644 services/ui/src/components/avatar.tsx create mode 100644 services/ui/src/components/badge.tsx create mode 100644 services/ui/src/components/breadcrumb.tsx create mode 100644 services/ui/src/components/button.tsx create mode 100644 services/ui/src/components/calendar.tsx create mode 100644 services/ui/src/components/card.tsx create mode 100644 services/ui/src/components/carousel.tsx create mode 100644 services/ui/src/components/chart.tsx create mode 100644 services/ui/src/components/checkbox.tsx create mode 100644 services/ui/src/components/collapsible.tsx create mode 100644 services/ui/src/components/command.tsx create mode 100644 services/ui/src/components/context-menu.tsx create mode 100644 services/ui/src/components/dialog.tsx create mode 100644 services/ui/src/components/drawer.tsx create mode 100644 services/ui/src/components/dropdown-menu.tsx create mode 100644 services/ui/src/components/form.tsx create mode 100644 services/ui/src/components/hover-card.tsx create mode 100644 services/ui/src/components/input-otp.tsx create mode 100644 services/ui/src/components/input.tsx create mode 100644 services/ui/src/components/label.tsx create mode 100644 services/ui/src/components/menubar.tsx create mode 100644 services/ui/src/components/navigation-menu.tsx create mode 100644 services/ui/src/components/pagination.tsx create mode 100644 services/ui/src/components/popover.tsx create mode 100644 services/ui/src/components/progress.tsx create mode 100644 services/ui/src/components/radio-group.tsx create mode 100644 services/ui/src/components/resizable.tsx create mode 100644 services/ui/src/components/scroll-area.tsx create mode 100644 services/ui/src/components/select.tsx create mode 100644 services/ui/src/components/separator.tsx create mode 100644 services/ui/src/components/sheet.tsx create mode 100644 services/ui/src/components/sidebar.tsx create mode 100644 services/ui/src/components/skeleton.tsx create mode 100644 services/ui/src/components/slider.tsx create mode 100644 services/ui/src/components/sonner.tsx create mode 100644 services/ui/src/components/switch.tsx create mode 100644 services/ui/src/components/table.tsx create mode 100644 services/ui/src/components/tabs.tsx create mode 100644 services/ui/src/components/textarea.tsx create mode 100644 services/ui/src/components/toggle-group.tsx create mode 100644 services/ui/src/components/toggle.tsx create mode 100644 services/ui/src/components/tooltip.tsx create mode 100644 services/ui/src/hooks/.gitkeep create mode 100644 services/ui/src/hooks/use-mobile.ts create mode 100644 services/ui/src/lib/utils.ts create mode 100644 services/ui/src/styles/globals.css create mode 100644 services/ui/tailwind.config.ts create mode 100644 services/ui/tsconfig.json create mode 100644 services/ui/tsconfig.lint.json create mode 100644 standards/blueprints/file-migration-list.md create mode 100644 standards/blueprints/next-app-router-structure.md create mode 100644 standards/blueprints/nextjs-templates.md create mode 100644 standards/blueprints/qwrk-laconic-migration-guide.md create mode 100644 standards/current-tech-reference.md create mode 100644 standards/documentation/COMPONENT_DOCUMENTATION.md create mode 100644 standards/documentation/FEATURE_BUILDING.md create mode 100644 standards/documentation/FEATURE_BUILDING_TEMPLATE.md create mode 100644 standards/documentation/README.md create mode 100644 standards/documentation/react-component-conventions.md create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json create mode 100644 turbo.json diff --git a/.cursor/rules/check-docs.mdc b/.cursor/rules/check-docs.mdc new file mode 100644 index 0000000..5162712 --- /dev/null +++ b/.cursor/rules/check-docs.mdc @@ -0,0 +1,11 @@ +--- +description: Check current progress +globs: +alwaysApply: false +--- +Check our progress and update the documentation + +[next-agent-01.md](mdc:next-agent-01.md) +[file-migration-list.md](mdc:standards/blueprints/file-migration-list.md) +[react-component-conventions.md](mdc:standards/documentation/react-component-conventions.md) + diff --git a/.cursor/rules/nextjs-filetype-scrutiny.mdc b/.cursor/rules/nextjs-filetype-scrutiny.mdc new file mode 100644 index 0000000..1d26036 --- /dev/null +++ b/.cursor/rules/nextjs-filetype-scrutiny.mdc @@ -0,0 +1,12 @@ +--- +description: Identify and execute best practice for nextjs file types +globs: app/**/*.tsx, page.tsx, layout.tsx, error.tsx, not-found.tsx, layout.tsx +alwaysApply: false +--- +# Follow Next.js 15 App Router current spec + - Identify the context and file type + - Note the file's role within this specific app strucure + - consider: async, dynamic routes, metadata, error handling, loading states + - Be aware of special files and their purposes +Next.js docs for detailed specifications and best practices. + - Document components using tsdoc diff --git a/.cursor/rules/ui-components-in-workspace.mdc b/.cursor/rules/ui-components-in-workspace.mdc new file mode 100644 index 0000000..933d979 --- /dev/null +++ b/.cursor/rules/ui-components-in-workspace.mdc @@ -0,0 +1,10 @@ +--- +description: When creating or updating UI, first use existing UI from @workspace/ui +globs: src/**/*.tsx +alwaysApply: false +--- + +# Always use existing UI before creating new components + +Find this in +`services/ui` available with import alias `@workspace/ui/*` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6c2dc6d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,30 @@ +# Setting Up Contribution Guidelines with CODEOWNERS + +You can create a CODEOWNERS file to define which files require specific approval before changes can be merged. This helps protect critical files in your project. + +## Steps to implement CODEOWNERS: + +1. Create a CODEOWNERS file in either the root directory, .github directory, or docs directory +2. Define file patterns and the users/teams who own those files + +Here's how to implement it: + +# CODEOWNERS file defines who needs to approve changes to specific files +# Format: file-pattern @user-or-team + +# Dev container configuration +.devcontainer/* @your-username + +# Core configuration files +.vscode/* @your-username +*.json @your-username + +# Critical application files that need review +/src/core/* @your-username @another-team-member +/services/api/* @api-team-name + +# Documentation changes can be reviewed by docs team +/docs/* @docs-team-or-username + +# Default owners for everything else +* @default-team-or-username diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..43d6094 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing to Laconic Core + +Thank you for considering contributing to our project! Here are some guidelines to help you get started. + +## Development Environment + +This project uses dev containers to ensure consistent development environments. To get started: + +1. Install [Docker](https://www.docker.com/products/docker-desktop) and [VS Code](https://code.visualstudio.com/) +2. Install the [Remote - Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension +3. Clone the repository and open it in VS Code +4. Click the green button in the bottom-left corner and select "Reopen in Container" + +## Protected Files + +Some files in this repository are protected and require specific approval before changes can be merged: + +- `.devcontainer/*` - Dev container configuration +- `.vscode/*` - VS Code workspace settings +- `core configuration files` - Project configuration files +- `critical application files` - Core functionality + +Please discuss any changes to these files with the maintainers before submitting pull requests. + +## Code Style + +This project uses Biome for formatting and linting. The dev container will automatically configure your editor to use the correct settings. + +## Submitting Changes + +1. Fork the repository +2. Create a new branch for your changes +3. Make your changes following the code style guidelines +4. Write tests for your changes +5. Submit a pull request + +## Pull Request Process + +1. Update the README.md with details of changes if applicable +2. The version numbers will be updated by maintainers following semantic versioning +3. Pull requests require approval from at least one maintainer +4. Once approved, maintainers will merge the PR + +## Questions? + +If you have questions, please open an issue or contact the maintainers. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..485770d --- /dev/null +++ b/.gitignore @@ -0,0 +1,75 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Testing +coverage + +# Next.js +.next/ +out/ +build +dist + +# Misc +.DS_Store +*.pem + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Turbo +.turbo + +# Vercel +.vercel + +# Build outputs +dist/ +build/ + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# Cache +.cache/ + +# IDE specific files +.idea/ +.vscode/* +!.vscode/extensions.json +!.vscode/launch.json +!.vscode/settings.json +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Logs +logs +*.log + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +.pnpm-store +.cursorignore \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..3ad8959 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +@cerc-io:registry=https://git.vdb.to/api/packages/cerc-io/npm/ +legacy-peer-deps=true +strict-peer-dependencies=false \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..c957e10 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["biomejs.biome", "ms-typescript.vscode-typescript-next"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8e460ce --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,38 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.addMissingImports.ts": "explicit", + "source.organizeImports.biome": "explicit", + "source.removeUnused.ts": "explicit" + }, + "editor.defaultFormatter": "biomejs.biome", + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "typescript.validate.enable": true, + "javascript.validate.enable": true, + "typescript.reportStyleChecksAsWarnings": true, + "typescript.surveys.enabled": false, + "prettier.enable": false, + "typescript.experimental.expandableHover": true, + "github.copilot.enable": { + "typescript": true, + "reacttypescript": true + }, + "github.copilot.chat.codeGeneration.useInstructionFiles": false +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c1a5a99 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 QWRK-ORG + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/.gitignore b/apps/.gitignore new file mode 100644 index 0000000..39b2403 --- /dev/null +++ b/apps/.gitignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules +.pnp +.pnp.js + +# Build outputs +dist +build +.next +out + +# Testing +coverage + +# Debug logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.idea +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/apps/backend/README.md b/apps/backend/README.md new file mode 100644 index 0000000..7fb3c8e --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,76 @@ +# backend + +This backend is a [node.js](https://nodejs.org/) [express.js](https://expressjs.com/) [apollo server](https://www.apollographql.com/docs/apollo-server/) project in a [yarn workspace](https://yarnpkg.com/features/workspaces). + +## Getting Started + +### Install dependencies + +In the root of the project, run: + +```zsh +yarn +``` + +### Build backend + +```zsh +yarn build --ignore frontend +``` + +### Environment variables + +#### Local + +Copy the `environments/local.toml.example` file to `environments/local.toml`: + +```zsh +cp environments/local.toml.example environments/local.toml +``` + +#### Staging environment variables + +In the deployment repository, update staging [staging/configmaps/config/prod.toml](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments/src/commit/318c2bc09f334dca79c3501838512749f9431bf1/deployments/staging/configmaps/config/prod.toml) + +#### Production environment variables + +In the deployment repository, update production [production/configmaps/config/prod.toml](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments/src/commit/318c2bc09f334dca79c3501838512749f9431bf1/deployments/production/configmaps/config/prod.toml) + +### Run development server + +```zsh +yarn start +``` + +## Deployment + +Clone the [deployer repository](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments): + +```zsh +git clone git@git.vdb.to:cerc-io/snowballtools-base-api-deployments.git +``` + +### Staging + +```zsh +echo trigger >> .gitea/workflows/triggers/staging-deploy +git commit -a -m "Deploy v0.0.8" # replace with version number +git push +``` + +### Production + +```zsh +echo trigger >> .gitea/workflows/triggers/production-deploy +git commit -a -m "Deploy v0.0.8" # replace with version number +git push +``` + +### Deployment status + +Dumb for now + +- [Staging](https://snowballtools-base-api.staging.apps.snowballtools.com/staging/version) +- [Production](https://snowballtools-base-api.apps.snowballtools.com/staging/version) + +Update version number manually in [routes/staging.ts](/packages/backend/src/routes/staging.ts) diff --git a/apps/backend/biome.json b/apps/backend/biome.json new file mode 100644 index 0000000..dd2cde3 --- /dev/null +++ b/apps/backend/biome.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 80 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off" + }, + "style": { + "noNonNullAssertion": "off" + } + } + }, + "javascript": { + "formatter": { + "enabled": true, + "quoteStyle": "single", + "trailingCommas": "none", + "semicolons": "asNeeded" + } + }, + "files": { + "ignore": ["dist/**/*", "node_modules/**/*", ".turbo/**/*"] + } +} diff --git a/apps/backend/environments/local.toml b/apps/backend/environments/local.toml new file mode 100644 index 0000000..233d495 --- /dev/null +++ b/apps/backend/environments/local.toml @@ -0,0 +1,43 @@ +[server] + host = "127.0.0.1" + port = 8000 + gqlPath = "/graphql" + [server.session] + secret = "" + # Frontend webapp URL origin + appOriginUrl = "http://localhost:3000" + # Set to true if server running behind proxy + trustProxy = false + # Backend URL hostname + domain = "localhost" + +[database] + dbPath = "db/snowball" + +[gitHub] + webhookUrl = "" + [gitHub.oAuth] + clientId = "" + clientSecret = "" + +[registryConfig] + fetchDeploymentRecordDelay = 5000 + checkAuctionStatusDelay = 5000 + restEndpoint = "http://localhost:1317" + gqlEndpoint = "http://localhost:9473/api" + chainId = "laconic_9000-1" + privateKey = "" + bondId = "" + authority = "" + [registryConfig.fee] + gas = "" + fees = "" + gasPrice = "1alnt" + +# Durations are set to 2 mins as deployers may take time with ongoing deployments and auctions +[auction] + commitFee = "100000" + commitsDuration = "120s" + revealFee = "100000" + revealsDuration = "120s" + denom = "alnt" diff --git a/apps/backend/environments/local.toml.example b/apps/backend/environments/local.toml.example new file mode 100644 index 0000000..233d495 --- /dev/null +++ b/apps/backend/environments/local.toml.example @@ -0,0 +1,43 @@ +[server] + host = "127.0.0.1" + port = 8000 + gqlPath = "/graphql" + [server.session] + secret = "" + # Frontend webapp URL origin + appOriginUrl = "http://localhost:3000" + # Set to true if server running behind proxy + trustProxy = false + # Backend URL hostname + domain = "localhost" + +[database] + dbPath = "db/snowball" + +[gitHub] + webhookUrl = "" + [gitHub.oAuth] + clientId = "" + clientSecret = "" + +[registryConfig] + fetchDeploymentRecordDelay = 5000 + checkAuctionStatusDelay = 5000 + restEndpoint = "http://localhost:1317" + gqlEndpoint = "http://localhost:9473/api" + chainId = "laconic_9000-1" + privateKey = "" + bondId = "" + authority = "" + [registryConfig.fee] + gas = "" + fees = "" + gasPrice = "1alnt" + +# Durations are set to 2 mins as deployers may take time with ongoing deployments and auctions +[auction] + commitFee = "100000" + commitsDuration = "120s" + revealFee = "100000" + revealsDuration = "120s" + denom = "alnt" diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 0000000..fbf128c --- /dev/null +++ b/apps/backend/package.json @@ -0,0 +1,68 @@ +{ + "name": "@qwrk/backend", + "license": "UNLICENSED", + "version": "1.0.0", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "dependencies": { + "@cerc-io/registry-sdk": "^0.2.11", + "@cosmjs/stargate": "^0.33.0", + "@graphql-tools/schema": "^10.0.2", + "@graphql-tools/utils": "^10.0.12", + "@octokit/oauth-app": "^6.1.0", + "@turnkey/sdk-server": "^0.1.0", + "@types/debug": "^4.1.5", + "@types/node": "^20.11.0", + "@types/semver": "^7.5.8", + "apollo-server-core": "^3.13.0", + "apollo-server-express": "^3.13.0", + "cookie-session": "^2.1.0", + "cors": "^2.8.5", + "debug": "^4.3.1", + "express": "^4.18.2", + "express-async-errors": "^3.1.1", + "express-session": "^1.18.0", + "fs-extra": "^11.2.0", + "graphql": "^16.8.1", + "luxon": "^3.5.0", + "nanoid": "3", + "nanoid-dictionary": "^5.0.0-beta.1", + "octokit": "^3.1.2", + "openpgp": "^6.0.1", + "reflect-metadata": "^0.2.1", + "semver": "^7.6.0", + "siwe": "^3.0.0", + "toml": "^3.0.0", + "ts-node": "^10.9.2", + "typeorm": "^0.3.19", + "typescript": "^5.3.3" + }, + "scripts": { + "start": "DEBUG=snowball:* node --enable-source-maps ./dist/index.js", + "start:dev": "DEBUG=snowball:* ts-node ./src/index.ts", + "copy-assets": "copyfiles -u 1 src/**/*.gql dist/", + "clean": "rm -rf ./dist", + "build": "pnpm clean && tsc && pnpm copy-assets", + "format": "biome format .", + "format:check": "biome format --check .", + "lint": "biome check .", + "test:registry:init": "DEBUG=snowball:* ts-node ./test/initialize-registry.ts", + "test:registry:publish-deploy-records": "DEBUG=snowball:* ts-node ./test/publish-deploy-records.ts", + "test:registry:publish-deployment-removal-records": "DEBUG=snowball:* ts-node ./test/publish-deployment-removal-records.ts", + "test:db:load:fixtures": "DEBUG=snowball:* ts-node ./test/initialize-db.ts", + "test:db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts" + }, + "devDependencies": { + "@biomejs/biome": "1.9.4", + "@types/cookie-session": "^2.0.49", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/express-session": "^1.17.10", + "@types/fs-extra": "^11.0.4", + "better-sqlite3": "^9.2.2", + "copyfiles": "^2.4.1", + "prettier": "^3.1.1", + "workspace": "^0.0.1-preview.1" + } +} diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts new file mode 100644 index 0000000..53a071a --- /dev/null +++ b/apps/backend/src/config.ts @@ -0,0 +1,66 @@ +export interface SessionConfig { + secret: string + appOriginUrl: string + trustProxy: boolean + domain: string +} + +export interface ServerConfig { + host: string + port: number + gqlPath?: string + sessionSecret: string + appOriginUrl: string + isProduction: boolean + session: SessionConfig +} + +export interface DatabaseConfig { + dbPath: string +} + +export interface GitHubConfig { + webhookUrl: string + oAuth: { + clientId: string + clientSecret: string + } +} + +export interface RegistryConfig { + restEndpoint: string + gqlEndpoint: string + chainId: string + privateKey: string + bondId: string + fetchDeploymentRecordDelay: number + checkAuctionStatusDelay: number + authority: string + fee: { + gas: string + fees: string + gasPrice: string + } +} + +export interface AuctionConfig { + commitFee: string + commitsDuration: string + revealFee: string + revealsDuration: string + denom: string +} + +export interface Config { + server: ServerConfig + database: DatabaseConfig + gitHub: GitHubConfig + registryConfig: RegistryConfig + auction: AuctionConfig + turnkey: { + apiBaseUrl: string + apiPublicKey: string + apiPrivateKey: string + defaultOrganizationId: string + } +} diff --git a/apps/backend/src/constants.ts b/apps/backend/src/constants.ts new file mode 100644 index 0000000..be2a86e --- /dev/null +++ b/apps/backend/src/constants.ts @@ -0,0 +1,7 @@ +import process from 'node:process' + +export const DEFAULT_CONFIG_FILE_PATH = + process.env.SNOWBALL_BACKEND_CONFIG_FILE_PATH || + 'apps/backend/environments/local.toml' + +export const DEFAULT_GQL_PATH = '/graphql' diff --git a/apps/backend/src/database.ts b/apps/backend/src/database.ts new file mode 100644 index 0000000..6528bfe --- /dev/null +++ b/apps/backend/src/database.ts @@ -0,0 +1,694 @@ +import assert from 'node:assert' +import path from 'node:path' +import debug from 'debug' +import { customAlphabet } from 'nanoid' +import { lowercase, numbers } from 'nanoid-dictionary' +import { + DataSource, + type DeepPartial, + type FindManyOptions, + type FindOneOptions, + type FindOptionsWhere, + IsNull, + Not +} from 'typeorm' + +import type { DatabaseConfig } from './config' +import { Deployer } from './entity/Deployer' +import { Deployment, DeploymentStatus } from './entity/Deployment' +import { Domain } from './entity/Domain' +import { EnvironmentVariable } from './entity/EnvironmentVariable' +import { Organization } from './entity/Organization' +import { Project } from './entity/Project' +import { ProjectMember } from './entity/ProjectMember' +import { User } from './entity/User' +import { UserOrganization } from './entity/UserOrganization' +import type { DNSRecordAttributes } from './types' +import { getEntities, loadAndSaveData } from './utils' + +const ORGANIZATION_DATA_PATH = '../test/fixtures/organizations.json' + +const log = debug('snowball:database') + +const nanoid = customAlphabet(lowercase + numbers, 8) + +// TODO: Fix order of methods +export class Database { + private dataSource: DataSource + + constructor({ dbPath }: DatabaseConfig) { + this.dataSource = new DataSource({ + type: 'better-sqlite3', + database: dbPath, + entities: [path.join(__dirname, '/entity/*')], + synchronize: true, + logging: false + }) + } + + async init(): Promise { + await this.dataSource.initialize() + log('database initialized') + + let organizations = await this.getOrganizations({}) + + // Load an organization if none exist + if (!organizations.length) { + const orgEntities = await getEntities( + path.resolve(__dirname, ORGANIZATION_DATA_PATH) + ) + organizations = await loadAndSaveData(Organization, this.dataSource, [ + orgEntities[0] + ]) + } + + // Hotfix for updating old DB data + if (organizations[0].slug === 'snowball-tools-1') { + const [orgEntity] = await getEntities( + path.resolve(__dirname, ORGANIZATION_DATA_PATH) + ) + + await this.updateOrganization(organizations[0].id, { + slug: orgEntity.slug as string, + name: orgEntity.name as string + }) + } + } + + async getUser(options: FindOneOptions): Promise { + const userRepository = this.dataSource.getRepository(User) + const user = await userRepository.findOne(options) + + return user + } + + async addUser(data: DeepPartial): Promise { + const userRepository = this.dataSource.getRepository(User) + const user = await userRepository.save(data) + + return user + } + + async updateUser(user: User, data: DeepPartial): Promise { + const userRepository = this.dataSource.getRepository(User) + const updateResult = await userRepository.update({ id: user.id }, data) + assert(updateResult.affected) + + return updateResult.affected > 0 + } + + async getOrganizations( + options: FindManyOptions + ): Promise { + const organizationRepository = this.dataSource.getRepository(Organization) + const organizations = await organizationRepository.find(options) + + return organizations + } + + async getOrganization( + options: FindOneOptions + ): Promise { + const organizationRepository = this.dataSource.getRepository(Organization) + const organization = await organizationRepository.findOne(options) + + return organization + } + + async getOrganizationsByUserId(userId: string): Promise { + const organizationRepository = this.dataSource.getRepository(Organization) + + const userOrgs = await organizationRepository.find({ + where: { + userOrganizations: { + member: { + id: userId + } + } + } + }) + + return userOrgs + } + + async addUserOrganization( + data: DeepPartial + ): Promise { + const userOrganizationRepository = + this.dataSource.getRepository(UserOrganization) + const newUserOrganization = await userOrganizationRepository.save(data) + + return newUserOrganization + } + + async updateOrganization( + organizationId: string, + data: DeepPartial + ): Promise { + const organizationRepository = this.dataSource.getRepository(Organization) + const updateResult = await organizationRepository.update( + { id: organizationId }, + data + ) + assert(updateResult.affected) + + return updateResult.affected > 0 + } + + async getProjects(options: FindManyOptions): Promise { + const projectRepository = this.dataSource.getRepository(Project) + const projects = await projectRepository.find(options) + + return projects + } + + async getProjectById(projectId: string): Promise { + const projectRepository = this.dataSource.getRepository(Project) + + const project = await projectRepository + .createQueryBuilder('project') + .leftJoinAndSelect( + 'project.deployments', + 'deployments', + 'deployments.isCurrent = true AND deployments.isCanonical = true' + ) + .leftJoinAndSelect('deployments.createdBy', 'user') + .leftJoinAndSelect('deployments.deployer', 'deployer') + .leftJoinAndSelect('project.owner', 'owner') + .leftJoinAndSelect('project.deployers', 'deployers') + .leftJoinAndSelect('project.organization', 'organization') + .where('project.id = :projectId', { + projectId + }) + .getOne() + + return project + } + + async allProjectsWithoutDeployments(): Promise { + const allProjects = await this.getProjects({ + where: { + auctionId: Not(IsNull()) + }, + relations: ['deployments'], + withDeleted: true + }) + + const projects = allProjects.filter((project) => { + if (project.deletedAt !== null) return false + + return project.deployments.length === 0 + }) + + return projects + } + + async getProjectsInOrganization( + userId: string, + organizationSlug: string + ): Promise { + const projectRepository = this.dataSource.getRepository(Project) + + const projects = await projectRepository + .createQueryBuilder('project') + .leftJoinAndSelect( + 'project.deployments', + 'deployments', + 'deployments.isCurrent = true AND deployments.isCanonical = true' + ) + .leftJoin('project.projectMembers', 'projectMembers') + .leftJoin('project.organization', 'organization') + .where( + '(project.ownerId = :userId OR projectMembers.userId = :userId) AND organization.slug = :organizationSlug', + { + userId, + organizationSlug + } + ) + .getMany() + + return projects + } + + /** + * Get deployments with specified filter + */ + async getDeployments( + options: FindManyOptions + ): Promise { + const deploymentRepository = this.dataSource.getRepository(Deployment) + const deployments = await deploymentRepository.find(options) + + return deployments + } + + async getDeploymentsByProjectId(projectId: string): Promise { + return this.getDeployments({ + relations: { + project: true, + createdBy: true, + deployer: true + }, + where: { + project: { + id: projectId + } + }, + order: { + createdAt: 'DESC' + } + }) + } + + async getNonCanonicalDeploymentsByProjectId( + projectId: string + ): Promise { + return this.getDeployments({ + relations: { + project: true, + createdBy: true, + deployer: true + }, + where: { + project: { + id: projectId + }, + isCanonical: false + }, + order: { + createdAt: 'DESC' + } + }) + } + + async getDeployment( + options: FindOneOptions + ): Promise { + const deploymentRepository = this.dataSource.getRepository(Deployment) + const deployment = await deploymentRepository.findOne(options) + + return deployment + } + + async getDomains(options: FindManyOptions): Promise { + const domainRepository = this.dataSource.getRepository(Domain) + const domains = await domainRepository.find(options) + + return domains + } + + async addDeployment(data: DeepPartial): Promise { + const deploymentRepository = this.dataSource.getRepository(Deployment) + + const id = nanoid() + + const updatedData = { + ...data, + id + } + const deployment = await deploymentRepository.save(updatedData) + + return deployment + } + + async getProjectMembersByProjectId( + projectId: string + ): Promise { + const projectMemberRepository = this.dataSource.getRepository(ProjectMember) + + const projectMembers = await projectMemberRepository.find({ + relations: { + project: true, + member: true + }, + where: { + project: { + id: projectId + } + } + }) + + return projectMembers + } + + async getEnvironmentVariablesByProjectId( + projectId: string, + filter?: FindOptionsWhere + ): Promise { + const environmentVariableRepository = + this.dataSource.getRepository(EnvironmentVariable) + + const environmentVariables = await environmentVariableRepository.find({ + where: { + project: { + id: projectId + }, + ...filter + } + }) + + return environmentVariables + } + + async removeProjectMemberById(projectMemberId: string): Promise { + const projectMemberRepository = this.dataSource.getRepository(ProjectMember) + + const deleteResult = await projectMemberRepository.delete({ + id: projectMemberId + }) + + if (deleteResult.affected) { + return deleteResult.affected > 0 + } + + return false + } + + async updateProjectMemberById( + projectMemberId: string, + data: DeepPartial + ): Promise { + const projectMemberRepository = this.dataSource.getRepository(ProjectMember) + const updateResult = await projectMemberRepository.update( + { id: projectMemberId }, + data + ) + + return Boolean(updateResult.affected) + } + + async addProjectMember( + data: DeepPartial + ): Promise { + const projectMemberRepository = this.dataSource.getRepository(ProjectMember) + const newProjectMember = await projectMemberRepository.save(data) + + return newProjectMember + } + + async addEnvironmentVariables( + data: DeepPartial[] + ): Promise { + const environmentVariableRepository = + this.dataSource.getRepository(EnvironmentVariable) + const savedEnvironmentVariables = + await environmentVariableRepository.save(data) + + return savedEnvironmentVariables + } + + async updateEnvironmentVariable( + environmentVariableId: string, + data: DeepPartial + ): Promise { + const environmentVariableRepository = + this.dataSource.getRepository(EnvironmentVariable) + const updateResult = await environmentVariableRepository.update( + { id: environmentVariableId }, + data + ) + + return Boolean(updateResult.affected) + } + + async deleteEnvironmentVariable( + environmentVariableId: string + ): Promise { + const environmentVariableRepository = + this.dataSource.getRepository(EnvironmentVariable) + const deleteResult = await environmentVariableRepository.delete({ + id: environmentVariableId + }) + + if (deleteResult.affected) { + return deleteResult.affected > 0 + } + + return false + } + + async getProjectMemberById(projectMemberId: string): Promise { + const projectMemberRepository = this.dataSource.getRepository(ProjectMember) + + const projectMemberWithProject = await projectMemberRepository.find({ + relations: { + project: { + owner: true + }, + member: true + }, + where: { + id: projectMemberId + } + }) + + if (projectMemberWithProject.length === 0) { + throw new Error('Member does not exist') + } + + return projectMemberWithProject[0] + } + + async getProjectsBySearchText( + userId: string, + searchText: string + ): Promise { + const projectRepository = this.dataSource.getRepository(Project) + + const projects = await projectRepository + .createQueryBuilder('project') + .leftJoinAndSelect('project.organization', 'organization') + .leftJoin('project.projectMembers', 'projectMembers') + .where( + '(project.owner = :userId OR projectMembers.member.id = :userId) AND project.name LIKE :searchText', + { + userId, + searchText: `%${searchText}%` + } + ) + .getMany() + + return projects + } + + async updateDeploymentById( + deploymentId: string, + data: DeepPartial + ): Promise { + return this.updateDeployment({ id: deploymentId }, data) + } + + async updateDeployment( + criteria: FindOptionsWhere, + data: DeepPartial + ): Promise { + const deploymentRepository = this.dataSource.getRepository(Deployment) + const updateResult = await deploymentRepository.update(criteria, data) + + return Boolean(updateResult.affected) + } + + async updateDeploymentsByProjectIds( + projectIds: string[], + data: DeepPartial + ): Promise { + const deploymentRepository = this.dataSource.getRepository(Deployment) + + const updateResult = await deploymentRepository + .createQueryBuilder() + .update(Deployment) + .set(data) + .where('projectId IN (:...projectIds)', { projectIds }) + .execute() + + return Boolean(updateResult.affected) + } + + async deleteDeploymentById(deploymentId: string): Promise { + const deploymentRepository = this.dataSource.getRepository(Deployment) + const deployment = await deploymentRepository.findOneOrFail({ + where: { + id: deploymentId + } + }) + + const deleteResult = await deploymentRepository.softRemove(deployment) + + return Boolean(deleteResult) + } + + async addProject( + user: User, + organizationId: string, + data: DeepPartial + ): Promise { + const projectRepository = this.dataSource.getRepository(Project) + + // TODO: Check if organization exists + const newProject = projectRepository.create(data) + // TODO: Set default empty array for webhooks in TypeORM + newProject.webhooks = [] + // TODO: Set icon according to framework + newProject.icon = '' + + newProject.owner = user + + newProject.organization = Object.assign(new Organization(), { + id: organizationId + }) + + return projectRepository.save(newProject) + } + + async saveProject(project: Project): Promise { + const projectRepository = this.dataSource.getRepository(Project) + + return projectRepository.save(project) + } + + async updateProjectById( + projectId: string, + data: DeepPartial + ): Promise { + const projectRepository = this.dataSource.getRepository(Project) + const updateResult = await projectRepository.update({ id: projectId }, data) + + return Boolean(updateResult.affected) + } + + async deleteProjectById(projectId: string): Promise { + const projectRepository = this.dataSource.getRepository(Project) + const project = await projectRepository.findOneOrFail({ + where: { + id: projectId + }, + relations: { + projectMembers: true + } + }) + + const deleteResult = await projectRepository.softRemove(project) + + return Boolean(deleteResult) + } + + async deleteDomainById(domainId: string): Promise { + const domainRepository = this.dataSource.getRepository(Domain) + + const deleteResult = await domainRepository.softDelete({ id: domainId }) + + if (deleteResult.affected) { + return deleteResult.affected > 0 + } + + return false + } + + async addDomain(data: DeepPartial): Promise { + const domainRepository = this.dataSource.getRepository(Domain) + const newDomain = await domainRepository.save(data) + + return newDomain + } + + async getDomain(options: FindOneOptions): Promise { + const domainRepository = this.dataSource.getRepository(Domain) + const domain = await domainRepository.findOne(options) + + return domain + } + + async updateDomainById( + domainId: string, + data: DeepPartial + ): Promise { + const domainRepository = this.dataSource.getRepository(Domain) + const updateResult = await domainRepository.update({ id: domainId }, data) + + return Boolean(updateResult.affected) + } + + async getDomainsByProjectId( + projectId: string, + filter?: FindOptionsWhere + ): Promise { + const domainRepository = this.dataSource.getRepository(Domain) + + const domains = await domainRepository.find({ + relations: { + redirectTo: true + }, + where: { + project: { + id: projectId + }, + ...filter + } + }) + + return domains + } + + async getOldestDomainByProjectId(projectId: string): Promise { + const domainRepository = this.dataSource.getRepository(Domain) + + const domain = await domainRepository.findOne({ + where: { + project: { + id: projectId + } + }, + order: { + createdAt: 'ASC' + } + }) + + return domain + } + + async getLatestDNSRecordByProjectId( + projectId: string + ): Promise { + const deploymentRepository = this.dataSource.getRepository(Deployment) + + const deployment = await deploymentRepository.findOne({ + where: { + project: { + id: projectId + }, + status: DeploymentStatus.Ready + }, + order: { + createdAt: 'DESC' + } + }) + + if (deployment === null) { + throw new Error(`No deployment found for project ${projectId}`) + } + + return deployment.dnsRecordData + } + + async addDeployer(data: DeepPartial): Promise { + const deployerRepository = this.dataSource.getRepository(Deployer) + const newDomain = await deployerRepository.save(data) + + return newDomain + } + + async getDeployers(): Promise { + const deployerRepository = this.dataSource.getRepository(Deployer) + const deployers = await deployerRepository.find() + return deployers + } + + async getDeployerByLRN(deployerLrn: string): Promise { + const deployerRepository = this.dataSource.getRepository(Deployer) + const deployer = await deployerRepository.findOne({ + where: { deployerLrn } + }) + + return deployer + } +} diff --git a/apps/backend/src/entity/Deployer.ts b/apps/backend/src/entity/Deployer.ts new file mode 100644 index 0000000..95feb76 --- /dev/null +++ b/apps/backend/src/entity/Deployer.ts @@ -0,0 +1,32 @@ +import { Column, Entity, ManyToMany, PrimaryColumn } from 'typeorm' +import { Project } from './Project' + +@Entity() +export class Deployer { + @PrimaryColumn('varchar') + deployerLrn!: string + + @Column('varchar') + deployerId!: string + + @Column('varchar') + deployerApiUrl!: string + + @Column('varchar') + baseDomain!: string + + @Column('varchar', { nullable: true }) + publicKey!: string | null + + @Column('varchar', { nullable: true }) + minimumPayment!: string | null + + @Column('varchar', { nullable: true }) + paymentAddress!: string | null + + @ManyToMany( + () => Project, + (project) => project.deployers + ) + projects!: Project[] +} diff --git a/apps/backend/src/entity/Deployment.ts b/apps/backend/src/entity/Deployment.ts new file mode 100644 index 0000000..5e02678 --- /dev/null +++ b/apps/backend/src/entity/Deployment.ts @@ -0,0 +1,159 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryColumn, + UpdateDateColumn +} from 'typeorm' + +import type { + AppDeploymentRecordAttributes, + AppDeploymentRemovalRecordAttributes, + DNSRecordAttributes +} from '../types' +import { Deployer } from './Deployer' +import { Project } from './Project' +import { User } from './User' + +export enum Environment { + Production = 'Production', + Preview = 'Preview', + Development = 'Development' +} + +export enum DeploymentStatus { + Building = 'Building', + Ready = 'Ready', + Error = 'Error', + Deleting = 'Deleting' +} + +export interface ApplicationDeploymentRequest { + type: string + version: string + name: string + application: string + lrn?: string + auction?: string + config: string + meta: string + payment?: string + dns?: string +} + +export interface ApplicationDeploymentRemovalRequest { + type: string + version: string + deployment: string + auction?: string + payment?: string +} + +export interface ApplicationRecord { + type: string + version: string + name: string + description?: string + homepage?: string + license?: string + author?: string + repository?: string[] + app_version?: string + repository_ref: string + app_type: string +} + +@Entity() +export class Deployment { + // TODO: set custom generated id + @PrimaryColumn('varchar') + id!: string + + @Column() + projectId!: string + + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'projectId' }) + project!: Project + + @Column('varchar') + branch!: string + + @Column('varchar') + commitHash!: string + + @Column('varchar') + commitMessage!: string + + @Column('varchar', { nullable: true }) + url!: string | null + + @Column('varchar') + applicationRecordId!: string + + @Column('simple-json') + applicationRecordData!: ApplicationRecord + + @Column('varchar', { nullable: true }) + applicationDeploymentRequestId!: string | null + + @Column('simple-json', { nullable: true }) + applicationDeploymentRequestData!: ApplicationDeploymentRequest | null + + @Column('varchar', { nullable: true }) + applicationDeploymentRecordId!: string | null + + @Column('simple-json', { nullable: true }) + applicationDeploymentRecordData!: AppDeploymentRecordAttributes | null + + @Column('varchar', { nullable: true }) + applicationDeploymentRemovalRequestId!: string | null + + @Column('simple-json', { nullable: true }) + applicationDeploymentRemovalRequestData!: ApplicationDeploymentRemovalRequest | null + + @Column('varchar', { nullable: true }) + applicationDeploymentRemovalRecordId!: string | null + + @Column('simple-json', { nullable: true }) + applicationDeploymentRemovalRecordData!: AppDeploymentRemovalRecordAttributes | null + + @Column('simple-json', { nullable: true }) + dnsRecordData!: DNSRecordAttributes | null + + @ManyToOne(() => Deployer) + @JoinColumn({ name: 'deployerLrn' }) + deployer!: Deployer + + @Column({ + enum: Environment + }) + environment!: Environment + + @Column('boolean', { default: false }) + isCurrent!: boolean + + @Column('boolean', { default: false }) + isCanonical!: boolean + + @Column({ + enum: DeploymentStatus + }) + status!: DeploymentStatus + + @ManyToOne(() => User) + @JoinColumn({ name: 'createdBy' }) + createdBy!: User + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date + + @DeleteDateColumn() + deletedAt!: Date | null +} diff --git a/apps/backend/src/entity/Domain.ts b/apps/backend/src/entity/Domain.ts new file mode 100644 index 0000000..93a263f --- /dev/null +++ b/apps/backend/src/entity/Domain.ts @@ -0,0 +1,59 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm' + +import { Project } from './Project' + +export enum Status { + Live = 'Live', + Pending = 'Pending' +} + +@Entity() +export class Domain { + @PrimaryGeneratedColumn('uuid') + id!: string + + @Column('varchar') + projectId!: string + + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'projectId' }) + project!: Project + + @Column('varchar', { length: 255, default: 'main' }) + branch!: string + + @Column('varchar', { length: 255 }) + name!: string + + @Column('string', { nullable: true }) + redirectToId!: string | null + + @ManyToOne(() => Domain) + @JoinColumn({ name: 'redirectToId' }) + // eslint-disable-next-line no-use-before-define + redirectTo!: Domain | null + + @Column({ + enum: Status, + default: Status.Pending + }) + status!: Status + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date + + @DeleteDateColumn() + deletedAt!: Date | null +} diff --git a/apps/backend/src/entity/EnvironmentVariable.ts b/apps/backend/src/entity/EnvironmentVariable.ts new file mode 100644 index 0000000..45ef5eb --- /dev/null +++ b/apps/backend/src/entity/EnvironmentVariable.ts @@ -0,0 +1,44 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm' + +import { Project } from './Project' + +enum Environment { + Production = 'Production', + Preview = 'Preview', + Development = 'Development' +} + +@Entity() +export class EnvironmentVariable { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'projectId' }) + project!: Project + + @Column({ + enum: Environment + }) + environment!: Environment + + @Column('varchar') + key!: string + + @Column('varchar') + value!: string + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date +} diff --git a/apps/backend/src/entity/Organization.ts b/apps/backend/src/entity/Organization.ts new file mode 100644 index 0000000..b2fe727 --- /dev/null +++ b/apps/backend/src/entity/Organization.ts @@ -0,0 +1,38 @@ +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn +} from 'typeorm' +import { UserOrganization } from './UserOrganization' + +@Entity() +@Unique(['slug']) +export class Organization { + @PrimaryGeneratedColumn('uuid') + id!: string + + @Column('varchar', { length: 255 }) + name!: string + + @Column('varchar') + slug!: string + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date + + @OneToMany( + () => UserOrganization, + (userOrganization) => userOrganization.organization, + { + cascade: ['soft-remove'] + } + ) + userOrganizations!: UserOrganization[] +} diff --git a/apps/backend/src/entity/Project.ts b/apps/backend/src/entity/Project.ts new file mode 100644 index 0000000..7de2418 --- /dev/null +++ b/apps/backend/src/entity/Project.ts @@ -0,0 +1,111 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm' + +import { Deployer } from './Deployer' +import { Deployment } from './Deployment' +import { Organization } from './Organization' +import { ProjectMember } from './ProjectMember' +import { User } from './User' + +@Entity() +export class Project { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => User) + @JoinColumn({ name: 'ownerId' }) + owner!: User + + @Column({ nullable: false }) + ownerId!: string + + @ManyToOne(() => Organization, { nullable: true }) + @JoinColumn({ name: 'organizationId' }) + organization!: Organization | null + + @Column('varchar') + organizationId!: string + + @Column('varchar') + name!: string + + @Column('varchar') + repository!: string + + @Column('varchar', { length: 255, default: 'main' }) + prodBranch!: string + + @Column('text', { default: '' }) + description!: string + + @Column('varchar', { nullable: true }) + auctionId!: string | null + + // Tx hash for sending coins from snowball to deployer + @Column('varchar', { nullable: true }) + txHash!: string | null + + @ManyToMany( + () => Deployer, + (deployer) => deployer.projects + ) + @JoinTable() + deployers!: Deployer[] + + @Column('boolean', { default: false, nullable: true }) + fundsReleased!: boolean + + // TODO: Compute template & framework in import repository + @Column('varchar', { nullable: true }) + template!: string | null + + @Column('varchar', { nullable: true }) + framework!: string | null + + // Address of the user who created the project i.e. requested deployments + @Column('varchar') + paymentAddress!: string + + @Column({ + type: 'simple-array' + }) + webhooks!: string[] + + @Column('varchar') + icon!: string + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date + + @DeleteDateColumn() + deletedAt!: Date | null + + @OneToMany( + () => Deployment, + (deployment) => deployment.project + ) + deployments!: Deployment[] + + @OneToMany( + () => ProjectMember, + (projectMember) => projectMember.project, + { + cascade: ['soft-remove'] + } + ) + projectMembers!: ProjectMember[] +} diff --git a/apps/backend/src/entity/ProjectMember.ts b/apps/backend/src/entity/ProjectMember.ts new file mode 100644 index 0000000..0e75ee0 --- /dev/null +++ b/apps/backend/src/entity/ProjectMember.ts @@ -0,0 +1,57 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn +} from 'typeorm' + +import { Project } from './Project' +import { User } from './User' + +export enum Permission { + View = 'View', + Edit = 'Edit' +} + +@Entity() +@Unique(['project', 'member']) +export class ProjectMember { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne( + () => User, + (user) => user.projectMembers + ) + @JoinColumn({ name: 'userId' }) + member!: User + + @ManyToOne( + () => Project, + (project) => project.projectMembers + ) + @JoinColumn({ name: 'projectId' }) + project!: Project + + @Column({ + type: 'simple-array' + }) + permissions!: Permission[] + + @Column('boolean', { default: false }) + isPending!: boolean + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date + + @DeleteDateColumn() + deletedAt!: Date | null +} diff --git a/apps/backend/src/entity/User.ts b/apps/backend/src/entity/User.ts new file mode 100644 index 0000000..86cdc76 --- /dev/null +++ b/apps/backend/src/entity/User.ts @@ -0,0 +1,65 @@ +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + Unique +} from 'typeorm' + +import { ProjectMember } from './ProjectMember' +import { UserOrganization } from './UserOrganization' + +@Entity() +@Unique(['email']) +@Unique(['ethAddress']) +export class User { + @PrimaryGeneratedColumn('uuid') + id!: string + + // TODO: Set ethAddress as ID + @Column() + ethAddress!: string + + @Column('varchar', { length: 255, nullable: true }) + name!: string | null + + @Column() + email!: string + + @Column('varchar', { nullable: true }) + gitHubToken!: string | null + + @Column('boolean', { default: false }) + isVerified!: boolean + + @CreateDateColumn() + createdAt!: Date + + @CreateDateColumn() + updatedAt!: Date + + @Column() + subOrgId!: string + + @Column() + turnkeyWalletId!: string + + @OneToMany( + () => ProjectMember, + (projectMember) => projectMember.project, + { + cascade: ['soft-remove'] + } + ) + projectMembers!: ProjectMember[] + + @OneToMany( + () => UserOrganization, + (UserOrganization) => UserOrganization.member, + { + cascade: ['soft-remove'] + } + ) + userOrganizations!: UserOrganization[] +} diff --git a/apps/backend/src/entity/UserOrganization.ts b/apps/backend/src/entity/UserOrganization.ts new file mode 100644 index 0000000..2f34491 --- /dev/null +++ b/apps/backend/src/entity/UserOrganization.ts @@ -0,0 +1,47 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm' + +import { Organization } from './Organization' +import { User } from './User' + +export enum Role { + Owner = 'Owner', + Maintainer = 'Maintainer', + Reader = 'Reader' +} + +@Entity() +export class UserOrganization { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => User) + @JoinColumn({ name: 'userId' }) + member!: User + + @ManyToOne(() => Organization) + @JoinColumn({ name: 'organizationId' }) + organization!: Organization + + @Column({ + enum: Role + }) + role!: Role + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date + + @DeleteDateColumn() + deletedAt!: Date | null +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts new file mode 100644 index 0000000..5195e9b --- /dev/null +++ b/apps/backend/src/index.ts @@ -0,0 +1,53 @@ +import 'express-async-errors' +import 'reflect-metadata' +import fs from 'node:fs' +import path from 'node:path' +import debug from 'debug' + +import { OAuthApp } from '@octokit/oauth-app' + +import { Database } from './database' +import { Registry } from './registry' +import { createResolvers } from './resolvers' +import { createAndStartServer } from './server' +import { Service } from './service' +import { getConfig } from './utils' + +const log = debug('snowball:server') +const OAUTH_CLIENT_TYPE = 'oauth-app' + +export const main = async (): Promise => { + const { server, database, gitHub, registryConfig } = await getConfig() + + const app = new OAuthApp({ + clientType: OAUTH_CLIENT_TYPE, + clientId: gitHub.oAuth.clientId, + clientSecret: gitHub.oAuth.clientSecret + }) + + const db = new Database(database) + await db.init() + + const registry = new Registry(registryConfig) + const service = new Service( + { gitHubConfig: gitHub, registryConfig }, + db, + app, + registry + ) + + const typeDefs = fs + .readFileSync(path.join(__dirname, 'schema.gql')) + .toString() + const resolvers = await createResolvers(service) + + await createAndStartServer(server, typeDefs, resolvers, service) +} + +main() + .then(() => { + log('Starting server...') + }) + .catch((err) => { + log(err) + }) diff --git a/apps/backend/src/registry.ts b/apps/backend/src/registry.ts new file mode 100644 index 0000000..b74ce8b --- /dev/null +++ b/apps/backend/src/registry.ts @@ -0,0 +1,624 @@ +import assert from 'node:assert' +import debug from 'debug' +import { DateTime } from 'luxon' +import type { Octokit } from 'octokit' +import * as openpgp from 'openpgp' +import { inc as semverInc } from 'semver' +import type { DeepPartial } from 'typeorm' + +import { + Account, + DEFAULT_GAS_ESTIMATION_MULTIPLIER, + Registry as LaconicRegistry, + getGasPrice, + parseGasAndFees +} from '@cerc-io/registry-sdk' +import type { DeliverTxResponse, IndexedTx } from '@cosmjs/stargate' + +import type { RegistryConfig } from './config' +import type { + ApplicationDeploymentRemovalRequest, + ApplicationDeploymentRequest, + ApplicationRecord, + Deployment +} from './entity/Deployment' +import type { + AppDeploymentRecord, + AppDeploymentRemovalRecord, + AuctionParams, + DeployerRecord, + RegistryRecord +} from './types' +import { + getConfig, + getRepoDetails, + registryTransactionWithRetry, + sleep +} from './utils' + +const log = debug('snowball:registry') + +const APP_RECORD_TYPE = 'ApplicationRecord' +const APP_DEPLOYMENT_AUCTION_RECORD_TYPE = 'ApplicationDeploymentAuction' +const APP_DEPLOYMENT_REQUEST_TYPE = 'ApplicationDeploymentRequest' +const APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE = + 'ApplicationDeploymentRemovalRequest' +const APP_DEPLOYMENT_RECORD_TYPE = 'ApplicationDeploymentRecord' +const APP_DEPLOYMENT_REMOVAL_RECORD_TYPE = 'ApplicationDeploymentRemovalRecord' +const WEBAPP_DEPLOYER_RECORD_TYPE = 'WebappDeployer' +const SLEEP_DURATION = 1000 + +// TODO: Move registry code to registry-sdk/watcher-ts +export class Registry { + private registry: LaconicRegistry + private registryConfig: RegistryConfig + + constructor(registryConfig: RegistryConfig) { + this.registryConfig = registryConfig + + const gasPrice = getGasPrice(registryConfig.fee.gasPrice) + + this.registry = new LaconicRegistry( + registryConfig.gqlEndpoint, + registryConfig.restEndpoint, + { chainId: registryConfig.chainId, gasPrice } + ) + } + + async createApplicationRecord({ + octokit, + repository, + commitHash, + appType + }: { + octokit: Octokit + repository: string + commitHash: string + appType: string + }): Promise<{ + applicationRecordId: string + applicationRecordData: ApplicationRecord + }> { + const { repo, repoUrl, packageJSON } = await getRepoDetails( + octokit, + repository, + commitHash + ) + // Use registry-sdk to publish record + // Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts/publish-app-record.sh + // Fetch previous records + const records = await this.registry.queryRecords( + { + type: APP_RECORD_TYPE, + name: packageJSON.name + }, + true + ) + + // Get next version of record + const bondRecords = records.filter( + (record: any) => record.bondId === this.registryConfig.bondId + ) + const [latestBondRecord] = bondRecords.sort( + (a: any, b: any) => + new Date(b.createTime).getTime() - new Date(a.createTime).getTime() + ) + const nextVersion = semverInc( + latestBondRecord?.attributes.version ?? '0.0.0', + 'patch' + ) + + assert(nextVersion, 'Application record version not valid') + + // Create record of type ApplicationRecord and publish + const applicationRecord = { + type: APP_RECORD_TYPE, + version: nextVersion, + repository_ref: commitHash, + repository: [repoUrl], + app_type: appType, + name: repo, + ...(packageJSON.description && { description: packageJSON.description }), + ...(packageJSON.homepage && { homepage: packageJSON.homepage }), + ...(packageJSON.license && { license: packageJSON.license }), + ...(packageJSON.author && { + author: + typeof packageJSON.author === 'object' + ? JSON.stringify(packageJSON.author) + : packageJSON.author + }), + ...(packageJSON.version && { app_version: packageJSON.version }) + } + + const result = await this.publishRecord(applicationRecord) + + log(`Published application record ${result.id}`) + log('Application record data:', applicationRecord) + + // TODO: Discuss computation of LRN + const lrn = this.getLrn(repo) + log(`Setting name: ${lrn} for record ID: ${result.id}`) + + const fee = parseGasAndFees( + this.registryConfig.fee.gas, + this.registryConfig.fee.fees + ) + + await sleep(SLEEP_DURATION) + await registryTransactionWithRetry(() => + this.registry.setName( + { + cid: result.id, + lrn + }, + this.registryConfig.privateKey, + fee + ) + ) + + await sleep(SLEEP_DURATION) + await registryTransactionWithRetry(() => + this.registry.setName( + { + cid: result.id, + lrn: `${lrn}@${applicationRecord.app_version}` + }, + this.registryConfig.privateKey, + fee + ) + ) + + await sleep(SLEEP_DURATION) + await registryTransactionWithRetry(() => + this.registry.setName( + { + cid: result.id, + lrn: `${lrn}@${applicationRecord.repository_ref}` + }, + this.registryConfig.privateKey, + fee + ) + ) + + return { + applicationRecordId: result.id, + applicationRecordData: applicationRecord + } + } + + async createApplicationDeploymentAuction( + appName: string, + octokit: Octokit, + auctionParams: AuctionParams, + data: DeepPartial + ): Promise<{ + applicationDeploymentAuctionId: string + }> { + assert(data.project?.repository, 'Project repository not found') + + await this.createApplicationRecord({ + octokit, + repository: data.project.repository, + appType: data.project!.template!, + commitHash: data.commitHash! + }) + + const lrn = this.getLrn(appName) + const config = await getConfig() + const auctionConfig = config.auction + + const fee = parseGasAndFees( + this.registryConfig.fee.gas, + this.registryConfig.fee.fees + ) + const auctionResult = await registryTransactionWithRetry(() => + this.registry.createProviderAuction( + { + commitFee: auctionConfig.commitFee, + commitsDuration: auctionConfig.commitsDuration, + revealFee: auctionConfig.revealFee, + revealsDuration: auctionConfig.revealsDuration, + denom: auctionConfig.denom, + maxPrice: auctionParams.maxPrice, + numProviders: auctionParams.numProviders + }, + this.registryConfig.privateKey, + fee + ) + ) + + if (!auctionResult.auction) { + throw new Error('Error creating auction') + } + + // Create record of type applicationDeploymentAuction and publish + const applicationDeploymentAuction = { + application: lrn, + auction: auctionResult.auction.id, + type: APP_DEPLOYMENT_AUCTION_RECORD_TYPE + } + + const result = await this.publishRecord(applicationDeploymentAuction) + + log(`Application deployment auction created: ${auctionResult.auction.id}`) + log(`Application deployment auction record published: ${result.id}`) + log('Application deployment auction data:', applicationDeploymentAuction) + + return { + applicationDeploymentAuctionId: auctionResult.auction.id + } + } + + async createApplicationDeploymentRequest(data: { + deployment: Deployment + appName: string + repository: string + auctionId?: string | null + lrn: string + apiUrl: string + environmentVariables: { [key: string]: string } + dns: string + requesterAddress: string + publicKey: string + payment?: string | null + }): Promise<{ + applicationDeploymentRequestId: string + applicationDeploymentRequestData: ApplicationDeploymentRequest + }> { + const lrn = this.getLrn(data.appName) + const records = await this.registry.resolveNames([lrn]) + const applicationRecord = records[0] + + if (!applicationRecord) { + throw new Error(`No record found for ${lrn}`) + } + + let hash: string | undefined + if (Object.keys(data.environmentVariables).length !== 0) { + hash = await this.generateConfigHash( + data.environmentVariables, + data.requesterAddress, + data.publicKey, + data.apiUrl + ) + } + + // Create record of type ApplicationDeploymentRequest and publish + const applicationDeploymentRequest = { + type: APP_DEPLOYMENT_REQUEST_TYPE, + version: '1.0.0', + name: `${applicationRecord.attributes.name}@${applicationRecord.attributes.app_version}`, + application: `${lrn}@${applicationRecord.attributes.app_version}`, + dns: data.dns, + + // https://git.vdb.to/cerc-io/laconic-registry-cli/commit/129019105dfb93bebcea02fde0ed64d0f8e5983b + config: JSON.stringify(hash ? { ref: hash } : {}), + meta: JSON.stringify({ + note: `Added by Snowball @ ${DateTime.utc().toFormat( + "EEE LLL dd HH:mm:ss 'UTC' yyyy" + )}`, + repository: data.repository, + repository_ref: data.deployment.commitHash + }), + deployer: data.lrn, + ...(data.auctionId && { auction: data.auctionId }), + ...(data.payment && { payment: data.payment }) + } + + await sleep(SLEEP_DURATION) + + const result = await this.publishRecord(applicationDeploymentRequest) + + log(`Application deployment request record published: ${result.id}`) + log('Application deployment request data:', applicationDeploymentRequest) + + return { + applicationDeploymentRequestId: result.id, + applicationDeploymentRequestData: applicationDeploymentRequest + } + } + + async getAuctionWinningDeployerRecords( + auctionId: string + ): Promise { + const records = await this.registry.getAuctionsByIds([auctionId]) + const auctionResult = records[0] + + const deployerRecords = [] + const { winnerAddresses } = auctionResult + + for (const auctionWinner of winnerAddresses) { + const records = await this.getDeployerRecordsByFilter({ + paymentAddress: auctionWinner + }) + + const newRecords = records.filter((record) => { + return record.names !== null && record.names.length > 0 + }) + + for (const record of newRecords) { + if (record.id) { + deployerRecords.push(record) + break + } + } + } + + return deployerRecords + } + + async releaseDeployerFunds(auctionId: string): Promise { + const fee = parseGasAndFees( + this.registryConfig.fee.gas, + this.registryConfig.fee.fees + ) + const auction = await registryTransactionWithRetry(() => + this.registry.releaseFunds( + { + auctionId + }, + this.registryConfig.privateKey, + fee + ) + ) + + return auction + } + + /** + * Fetch ApplicationDeploymentRecords for deployments + */ + async getDeploymentRecords( + deployments: Deployment[] + ): Promise { + // Fetch ApplicationDeploymentRecords for corresponding ApplicationRecord set in deployments + // TODO: Implement Laconicd GQL query to filter records by multiple values for an attribute + const records = await this.registry.queryRecords( + { + type: APP_DEPLOYMENT_RECORD_TYPE + }, + true + ) + + // Filter records with ApplicationDeploymentRequestId ID + return records.filter((record: AppDeploymentRecord) => + deployments.some( + (deployment) => + deployment.applicationDeploymentRequestId === + record.attributes.request + ) + ) + } + + /** + * Fetch WebappDeployer Records by filter + */ + async getDeployerRecordsByFilter(filter: { [key: string]: any }): Promise< + DeployerRecord[] + > { + return this.registry.queryRecords( + { + type: WEBAPP_DEPLOYER_RECORD_TYPE, + ...filter + }, + true + ) + } + + /** + * Fetch ApplicationDeploymentRecords by filter + */ + async getDeploymentRecordsByFilter(filter: { [key: string]: any }): Promise< + AppDeploymentRecord[] + > { + return this.registry.queryRecords( + { + type: APP_DEPLOYMENT_RECORD_TYPE, + ...filter + }, + true + ) + } + + /** + * Fetch ApplicationDeploymentRemovalRecords for deployments + */ + async getDeploymentRemovalRecords( + deployments: Deployment[] + ): Promise { + // Fetch ApplicationDeploymentRemovalRecords for corresponding ApplicationDeploymentRecord set in deployments + const records = await this.registry.queryRecords( + { + type: APP_DEPLOYMENT_REMOVAL_RECORD_TYPE + }, + true + ) + + // Filter records with ApplicationDeploymentRecord and ApplicationDeploymentRemovalRequest IDs + return records.filter((record: AppDeploymentRemovalRecord) => + deployments.some( + (deployment) => + deployment.applicationDeploymentRemovalRequestId === + record.attributes.request && + deployment.applicationDeploymentRecordId === + record.attributes.deployment + ) + ) + } + + /** + * Fetch record by Id + */ + async getRecordById(id: string): Promise { + const [record] = await this.registry.getRecordsByIds([id]) + return record ?? null + } + + async createApplicationDeploymentRemovalRequest(data: { + deploymentId: string + deployerLrn: string + auctionId?: string | null + payment?: string | null + }): Promise<{ + applicationDeploymentRemovalRequestId: string + applicationDeploymentRemovalRequestData: ApplicationDeploymentRemovalRequest + }> { + const applicationDeploymentRemovalRequest = { + type: APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE, + version: '1.0.0', + deployment: data.deploymentId, + deployer: data.deployerLrn, + ...(data.auctionId && { auction: data.auctionId }), + ...(data.payment && { payment: data.payment }) + } + + const result = await this.publishRecord(applicationDeploymentRemovalRequest) + + log(`Application deployment removal request record published: ${result.id}`) + log( + 'Application deployment removal request data:', + applicationDeploymentRemovalRequest + ) + + return { + applicationDeploymentRemovalRequestId: result.id, + applicationDeploymentRemovalRequestData: + applicationDeploymentRemovalRequest + } + } + + async getCompletedAuctionIds(auctionIds: string[]): Promise { + if (auctionIds.length === 0) { + return [] + } + + const auctions = await this.registry.getAuctionsByIds(auctionIds) + + const completedAuctions = auctions + .filter( + (auction: { id: string; status: string }) => + auction.status === 'completed' + ) + .map((auction: { id: string; status: string }) => auction.id) + + return completedAuctions + } + + async publishRecord(recordData: any): Promise { + const fee = parseGasAndFees( + this.registryConfig.fee.gas, + this.registryConfig.fee.fees + ) + + const result = await registryTransactionWithRetry(() => + this.registry.setRecord( + { + privateKey: this.registryConfig.privateKey, + record: recordData, + bondId: this.registryConfig.bondId + }, + this.registryConfig.privateKey, + fee + ) + ) + + return result + } + + async getRecordsByName(name: string): Promise { + return this.registry.resolveNames([name]) + } + + async getAuctionData(auctionId: string): Promise { + return this.registry.getAuctionsByIds([auctionId]) + } + + async sendTokensToAccount( + receiverAddress: string, + amount: string + ): Promise { + const fee = parseGasAndFees( + this.registryConfig.fee.gas, + this.registryConfig.fee.fees + ) + const account = await this.getAccount() + const laconicClient = await this.registry.getLaconicClient(account) + const txResponse: DeliverTxResponse = await registryTransactionWithRetry( + () => + laconicClient.sendTokens( + account.address, + receiverAddress, + [ + { + denom: 'alnt', + amount + } + ], + fee || DEFAULT_GAS_ESTIMATION_MULTIPLIER + ) + ) + + return txResponse + } + + async getAccount(): Promise { + const account = new Account( + Buffer.from(this.registryConfig.privateKey, 'hex') + ) + await account.init() + + return account + } + + async getTxResponse(txHash: string): Promise { + const account = await this.getAccount() + const laconicClient = await this.registry.getLaconicClient(account) + const txResponse: IndexedTx | null = await laconicClient.getTx(txHash) + + return txResponse + } + + getLrn(appName: string): string { + assert(this.registryConfig.authority, "Authority doesn't exist") + return `lrn://${this.registryConfig.authority}/applications/${appName}` + } + + async generateConfigHash( + environmentVariables: { [key: string]: string }, + requesterAddress: string, + pubKey: string, + url: string + ): Promise { + // Config to be encrypted + const config = { + authorized: [requesterAddress], + config: { env: environmentVariables } + } + + // Serialize the config + const serialized = JSON.stringify(config, null, 2) + + const armoredKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n${pubKey}\n\n-----END PGP PUBLIC KEY BLOCK-----` + const publicKey = await openpgp.readKey({ armoredKey }) + + // Encrypt the config + const encrypted = await openpgp.encrypt({ + message: await openpgp.createMessage({ text: serialized }), + encryptionKeys: publicKey, + format: 'binary' + }) + + // Get the hash after uploading encrypted config + const response = await fetch(`${url}/upload/config`, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: encrypted + }) + + const configHash = await response.json() + + return configHash.id + } +} diff --git a/apps/backend/src/resolvers.ts b/apps/backend/src/resolvers.ts new file mode 100644 index 0000000..38a4a31 --- /dev/null +++ b/apps/backend/src/resolvers.ts @@ -0,0 +1,413 @@ +import debug from 'debug' +import type { DeepPartial, FindOptionsWhere } from 'typeorm' + +import type { Domain } from './entity/Domain' +import type { EnvironmentVariable } from './entity/EnvironmentVariable' +import type { Project } from './entity/Project' +import type { Permission } from './entity/ProjectMember' +import type { Service } from './service' +import type { + AddProjectFromTemplateInput, + AuctionParams, + EnvironmentVariables +} from './types' + +const log = debug('snowball:resolver') + +export const createResolvers = async (service: Service): Promise => { + return { + Query: { + // TODO: add custom type for context + user: (_: any, __: any, context: any) => { + return context.user + }, + + organizations: async (_: any, __: any, context: any) => { + return service.getOrganizationsByUserId(context.user) + }, + + project: async ( + _: any, + { projectId }: { projectId: string }, + context: any + ) => { + return service.getProjectById(context.user, projectId) + }, + + projectsInOrganization: async ( + _: any, + { organizationSlug }: { organizationSlug: string }, + context: any + ) => { + return service.getProjectsInOrganization(context.user, organizationSlug) + }, + + deployments: async (_: any, { projectId }: { projectId: string }) => { + return service.getNonCanonicalDeploymentsByProjectId(projectId) + }, + + environmentVariables: async ( + _: any, + { projectId }: { projectId: string } + ) => { + return service.getEnvironmentVariablesByProjectId(projectId) + }, + + projectMembers: async (_: any, { projectId }: { projectId: string }) => { + return service.getProjectMembersByProjectId(projectId) + }, + + searchProjects: async ( + _: any, + { searchText }: { searchText: string }, + context: any + ) => { + return service.searchProjects(context.user, searchText) + }, + + domains: async ( + _: any, + { + projectId, + filter + }: { projectId: string; filter?: FindOptionsWhere } + ) => { + return service.getDomainsByProjectId(projectId, filter) + }, + + getAuctionData: async (_: any, { auctionId }: { auctionId: string }) => { + return service.getAuctionData(auctionId) + }, + + deployers: async (_: any, __: any) => { + return service.getDeployers() + }, + + address: async (_: any, __: any) => { + return service.getAddress() + }, + + verifyTx: async ( + _: any, + { + txHash, + amount, + senderAddress + }: { txHash: string; amount: string; senderAddress: string } + ) => { + return service.verifyTx(txHash, amount, senderAddress) + }, + + latestDNSRecord: async (_: any, { projectId }: { projectId: string }) => { + return service.getLatestDNSRecordByProjectId(projectId) + } + }, + + // TODO: Return error in GQL response + Mutation: { + removeProjectMember: async ( + _: any, + { projectMemberId }: { projectMemberId: string }, + context: any + ) => { + try { + return await service.removeProjectMember( + context.user, + projectMemberId + ) + } catch (err) { + log(err) + return false + } + }, + + updateProjectMember: async ( + _: any, + { + projectMemberId, + data + }: { + projectMemberId: string + data: { + permissions: Permission[] + } + } + ) => { + try { + return await service.updateProjectMember(projectMemberId, data) + } catch (err) { + log(err) + return false + } + }, + + addProjectMember: async ( + _: any, + { + projectId, + data + }: { + projectId: string + data: { + email: string + permissions: Permission[] + } + } + ) => { + try { + return Boolean(await service.addProjectMember(projectId, data)) + } catch (err) { + log(err) + return false + } + }, + + addEnvironmentVariables: async ( + _: any, + { + projectId, + data + }: { + projectId: string + data: { environments: string[]; key: string; value: string }[] + } + ) => { + try { + return Boolean(await service.addEnvironmentVariables(projectId, data)) + } catch (err) { + log(err) + return false + } + }, + + updateEnvironmentVariable: async ( + _: any, + { + environmentVariableId, + data + }: { + environmentVariableId: string + data: DeepPartial + } + ) => { + try { + return await service.updateEnvironmentVariable( + environmentVariableId, + data + ) + } catch (err) { + log(err) + return false + } + }, + + removeEnvironmentVariable: async ( + _: any, + { environmentVariableId }: { environmentVariableId: string } + ) => { + try { + return await service.removeEnvironmentVariable(environmentVariableId) + } catch (err) { + log(err) + return false + } + }, + + updateDeploymentToProd: async ( + _: any, + { deploymentId }: { deploymentId: string }, + context: any + ) => { + try { + return Boolean( + await service.updateDeploymentToProd(context.user, deploymentId) + ) + } catch (err) { + log(err) + return false + } + }, + + addProjectFromTemplate: async ( + _: any, + { + organizationSlug, + data, + lrn, + auctionParams, + environmentVariables + }: { + organizationSlug: string + data: AddProjectFromTemplateInput + lrn: string + auctionParams: AuctionParams + environmentVariables: EnvironmentVariables[] + }, + context: any + ) => { + try { + return await service.addProjectFromTemplate( + context.user, + organizationSlug, + data, + lrn, + auctionParams, + environmentVariables + ) + } catch (err) { + log(err) + throw err + } + }, + + addProject: async ( + _: any, + { + organizationSlug, + data, + lrn, + auctionParams, + environmentVariables + }: { + organizationSlug: string + data: DeepPartial + lrn: string + auctionParams: AuctionParams + environmentVariables: EnvironmentVariables[] + }, + context: any + ) => { + try { + return await service.addProject( + context.user, + organizationSlug, + data, + lrn, + auctionParams, + environmentVariables + ) + } catch (err) { + log(err) + throw err + } + }, + + updateProject: async ( + _: any, + { projectId, data }: { projectId: string; data: DeepPartial } + ) => { + try { + return await service.updateProject(projectId, data) + } catch (err) { + log(err) + return false + } + }, + + redeployToProd: async ( + _: any, + { deploymentId }: { deploymentId: string }, + context: any + ) => { + try { + return Boolean( + await service.redeployToProd(context.user, deploymentId) + ) + } catch (err) { + log(err) + return false + } + }, + + deleteProject: async (_: any, { projectId }: { projectId: string }) => { + try { + return await service.deleteProject(projectId) + } catch (err) { + log(err) + return false + } + }, + + deleteDomain: async (_: any, { domainId }: { domainId: string }) => { + try { + return await service.deleteDomain(domainId) + } catch (err) { + log(err) + return false + } + }, + + rollbackDeployment: async ( + _: any, + { projectId, deploymentId }: { deploymentId: string; projectId: string } + ) => { + try { + return await service.rollbackDeployment(projectId, deploymentId) + } catch (err) { + log(err) + return false + } + }, + + deleteDeployment: async ( + _: any, + { deploymentId }: { deploymentId: string } + ) => { + try { + return await service.deleteDeployment(deploymentId) + } catch (err) { + log(err) + return false + } + }, + + addDomain: async ( + _: any, + { projectId, data }: { projectId: string; data: { name: string } } + ) => { + try { + return Boolean(await service.addDomain(projectId, data)) + } catch (err) { + log(err) + return false + } + }, + + updateDomain: async ( + _: any, + { domainId, data }: { domainId: string; data: DeepPartial } + ) => { + try { + return await service.updateDomain(domainId, data) + } catch (err) { + log(err) + return false + } + }, + + authenticateGitHub: async ( + _: any, + { code }: { code: string }, + context: any + ) => { + try { + return await service.authenticateGitHub(code, context.user) + } catch (err) { + log(err) + return false + } + }, + + unauthenticateGitHub: async (_: any, __: object, context: any) => { + try { + return service.unauthenticateGitHub(context.user, { + gitHubToken: null + }) + } catch (err) { + log(err) + return false + } + } + } + } +} diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts new file mode 100644 index 0000000..3d2ff5d --- /dev/null +++ b/apps/backend/src/routes/auth.ts @@ -0,0 +1,97 @@ +import { Router } from 'express' +import { SiweMessage } from 'siwe' +import type { Service } from '../service' +import { authenticateUser, createUser } from '../turnkey-backend' + +const router: Router = Router() + +// +// Turnkey +// +router.get('/registration/:email', async (req, res) => { + const service: Service = req.app.get('service') + const user = await service.getUserByEmail(req.params.email) + if (user) { + return res.send({ subOrganizationId: user?.subOrgId }) + } + + return res.sendStatus(204) +}) + +router.post('/register', async (req, res) => { + console.log('Register', req.body) + const { email, challenge, attestation } = req.body + const user = await createUser(req.app.get('service'), { + challenge, + attestation, + userEmail: email, + userName: email.split('@')[0] + }) + req.session.address = user.id + res.sendStatus(200) +}) + +router.post('/authenticate', async (req, res) => { + console.log('Authenticate', req.body) + const { signedWhoamiRequest } = req.body + const user = await authenticateUser( + req.app.get('service'), + signedWhoamiRequest + ) + if (user) { + req.session.address = user.id + res.sendStatus(200) + } else { + res.sendStatus(401) + } +}) + +// +// SIWE Auth +// +router.post('/validate', async (req, res) => { + const { message, signature } = req.body + const { success, data } = await new SiweMessage(message).verify({ + signature + }) + + if (!success) { + return res.send({ success }) + } + const service: Service = req.app.get('service') + const user = await service.getUserByEthAddress(data.address) + + if (!user) { + const newUser = await service.createUser({ + ethAddress: data.address, + email: `${data.address}@example.com`, + subOrgId: '', + turnkeyWalletId: '' + }) + + // SIWESession from the web3modal library requires both address and chain ID + req.session.address = newUser.id + req.session.chainId = data.chainId + } else { + req.session.address = user.id + req.session.chainId = data.chainId + } + + res.send({ success }) +}) + +// +// General +// +router.get('/session', (req, res) => { + if (req.session.address && req.session.chainId) { + res.send({ + address: req.session.address, + chainId: req.session.chainId + }) + } else { + res.status(401).send({ error: 'Unauthorized: No active session' }) + } +}) + +export default router diff --git a/apps/backend/src/routes/github.ts b/apps/backend/src/routes/github.ts new file mode 100644 index 0000000..a64f023 --- /dev/null +++ b/apps/backend/src/routes/github.ts @@ -0,0 +1,26 @@ +import debug from 'debug' +import { Router } from 'express' + +import type { Service } from '../service' + +const log = debug('snowball:routes-github') +const router: Router = Router() + +/* POST GitHub webhook handler */ +// https://docs.github.com/en/webhooks/using-webhooks/handling-webhook-deliveries#javascript-example +router.post('/webhook', async (req, res) => { + // Server should respond with a 2XX response within 10 seconds of receiving a webhook delivery + // If server takes longer than that to respond, then GitHub terminates the connection and considers the delivery a failure + res.status(202).send('Accepted') + + const service = req.app.get('service') as Service + const githubEvent = req.headers['x-github-event'] + log(`Received GitHub webhook for event ${githubEvent}`) + + if (githubEvent === 'push') { + // Create deployments using push event data + await service.handleGitHubPush(req.body) + } +}) + +export default router diff --git a/apps/backend/src/routes/staging.ts b/apps/backend/src/routes/staging.ts new file mode 100644 index 0000000..e6cbd0b --- /dev/null +++ b/apps/backend/src/routes/staging.ts @@ -0,0 +1,9 @@ +import { Router } from 'express' + +const router: Router = Router() + +router.get('/version', async (_req, res) => { + return res.send({ version: '0.0.9' }) +}) + +export default router diff --git a/apps/backend/src/schema.gql b/apps/backend/src/schema.gql new file mode 100644 index 0000000..4dbff1d --- /dev/null +++ b/apps/backend/src/schema.gql @@ -0,0 +1,337 @@ +enum Role { + Owner + Maintainer + Reader +} + +enum Permission { + View + Edit +} + +enum Environment { + Production + Preview + Development +} + +enum DeploymentStatus { + Building + Ready + Error + Deleting +} + +enum AuctionStatus { + completed + reveal + commit + expired +} + +enum DomainStatus { + Live + Pending +} + +type User { + id: String! + name: String + email: String! + organizations: [Organization!] + projects: [Project!] + isVerified: Boolean! + createdAt: String! + updatedAt: String! + gitHubToken: String +} + +type Organization { + id: String! + name: String! + slug: String! + projects: [Project!] + createdAt: String! + updatedAt: String! + members: [OrganizationMember!] +} + +type OrganizationMember { + id: String! + member: User! + role: Role! + createdAt: String! + updatedAt: String! +} + +type Project { + id: String! + owner: User! + deployments: [Deployment!] + name: String! + repository: String! + prodBranch: String! + description: String + deployers: [Deployer!] + auctionId: String + fundsReleased: Boolean + template: String + framework: String + paymentAddress: String! + txHash: String! + webhooks: [String!] + members: [ProjectMember!] + environmentVariables: [EnvironmentVariable!] + createdAt: String! + updatedAt: String! + organization: Organization! + icon: String + baseDomains: [String!] +} + +type ProjectMember { + id: String! + member: User! + permissions: [Permission!]! + isPending: Boolean! + createdAt: String! + updatedAt: String! +} + +type Deployment { + id: String! + branch: String! + commitHash: String! + commitMessage: String! + url: String + environment: Environment! + deployer: Deployer + applicationDeploymentRequestId: String + applicationDeploymentRecordData: AppDeploymentRecordAttributes + isCurrent: Boolean! + baseDomain: String + status: DeploymentStatus! + createdAt: String! + updatedAt: String! + createdBy: User! +} + +type Domain { + id: String! + branch: String! + name: String! + redirectTo: Domain + status: DomainStatus! + createdAt: String! + updatedAt: String! +} + +type EnvironmentVariable { + id: String! + environment: Environment! + key: String! + value: String! + createdAt: String! + updatedAt: String! +} + +type Deployer { + deployerLrn: String! + deployerId: String! + deployerApiUrl: String! + minimumPayment: String + paymentAddress: String + createdAt: String! + updatedAt: String! + baseDomain: String +} + +type AuthResult { + token: String! +} + +input AddEnvironmentVariableInput { + environments: [Environment!]! + key: String! + value: String! +} + +input AddProjectFromTemplateInput { + templateOwner: String! + templateRepo: String! + owner: String! + name: String! + isPrivate: Boolean! + paymentAddress: String! + txHash: String! +} + +input AddProjectInput { + name: String! + repository: String! + prodBranch: String! + template: String + paymentAddress: String! + txHash: String! +} + +input UpdateProjectInput { + name: String + description: String + prodBranch: String + organizationId: String + webhooks: [String!] +} + +input AddDomainInput { + name: String! +} + +input UpdateDomainInput { + name: String + branch: String + redirectToId: String +} + +input UpdateEnvironmentVariableInput { + key: String + value: String +} + +input AddProjectMemberInput { + email: String! + permissions: [Permission!] +} + +input UpdateProjectMemberInput { + permissions: [Permission] +} + +input FilterDomainsInput { + branch: String + status: DomainStatus +} + +type Fee { + type: String! + quantity: String! +} + +type Bid { + auctionId: String! + bidderAddress: String! + status: String! + commitHash: String! + commitTime: String + commitFee: Fee + revealTime: String + revealFee: Fee + bidAmount: Fee +} + +type Auction { + id: String! + kind: String! + status: String! + ownerAddress: String! + createTime: String! + commitsEndTime: String! + revealsEndTime: String! + commitFee: Fee! + revealFee: Fee! + minimumBid: Fee + winnerAddresses: [String!]! + winnerBids: [Fee!] + winnerPrice: Fee + maxPrice: Fee + numProviders: Int! + fundsReleased: Boolean! + bids: [Bid!]! +} + +type DNSRecordAttributes { + name: String + value: String + request: String + resourceType: String + version: String +} + +type AppDeploymentRecordAttributes { + application: String + auction: String + deployer: String + dns: String + meta: String + name: String + request: String + type: String + url: String + version: String +} + +input AuctionParams { + maxPrice: String + numProviders: Int +} + +type Query { + user: User! + organizations: [Organization!] + projects: [Project!] + projectsInOrganization(organizationSlug: String!): [Project!] + project(projectId: String!): Project + deployments(projectId: String!): [Deployment!] + environmentVariables(projectId: String!): [EnvironmentVariable!] + projectMembers(projectId: String!): [ProjectMember!] + searchProjects(searchText: String!): [Project!] + getAuctionData(auctionId: String!): Auction! + latestDNSRecord(projectId: String!): DNSRecordAttributes + domains(projectId: String!, filter: FilterDomainsInput): [Domain] + deployers: [Deployer] + address: String! + verifyTx(txHash: String!, amount: String!, senderAddress: String!): Boolean! +} + +type Mutation { + addProjectMember(projectId: String!, data: AddProjectMemberInput): Boolean! + updateProjectMember( + projectMemberId: String! + data: UpdateProjectMemberInput + ): Boolean! + removeProjectMember(projectMemberId: String!): Boolean! + addEnvironmentVariables( + projectId: String! + data: [AddEnvironmentVariableInput!] + ): Boolean! + updateEnvironmentVariable( + environmentVariableId: String! + data: UpdateEnvironmentVariableInput! + ): Boolean! + removeEnvironmentVariable(environmentVariableId: String!): Boolean! + updateDeploymentToProd(deploymentId: String!): Boolean! + addProjectFromTemplate( + organizationSlug: String! + data: AddProjectFromTemplateInput + lrn: String + auctionParams: AuctionParams + environmentVariables: [AddEnvironmentVariableInput!] + ): Project! + addProject( + organizationSlug: String! + data: AddProjectInput! + lrn: String + auctionParams: AuctionParams + environmentVariables: [AddEnvironmentVariableInput!] + ): Project! + updateProject(projectId: String!, data: UpdateProjectInput): Boolean! + redeployToProd(deploymentId: String!): Boolean! + deleteProject(projectId: String!): Boolean! + deleteDomain(domainId: String!): Boolean! + rollbackDeployment(projectId: String!, deploymentId: String!): Boolean! + deleteDeployment(deploymentId: String!): Boolean! + addDomain(projectId: String!, data: AddDomainInput!): Boolean! + updateDomain(domainId: String!, data: UpdateDomainInput!): Boolean! + authenticateGitHub(code: String!): AuthResult! + unauthenticateGitHub: Boolean! +} diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts new file mode 100644 index 0000000..af3ff87 --- /dev/null +++ b/apps/backend/src/server.ts @@ -0,0 +1,130 @@ +import { + ApolloServerPluginDrainHttpServer, + ApolloServerPluginLandingPageLocalDefault, + AuthenticationError +} from 'apollo-server-core' +import { ApolloServer } from 'apollo-server-express' +import cors from 'cors' +import debug from 'debug' +import express from 'express' +import session from 'express-session' +import { createServer } from 'node:http' + +import { makeExecutableSchema } from '@graphql-tools/schema' +import type { TypeSource } from '@graphql-tools/utils' + +import type { ServerConfig } from './config' +import authRouter from './routes/auth' +import githubRouter from './routes/github' +import stagingRouter from './routes/staging' +import type { Service } from './service' + +const log = debug('snowball:server') + +// Set cookie expiration to 1 month in milliseconds +const COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000 + +declare module 'express-session' { + interface SessionData { + address: string + chainId: number + } +} + +export const createAndStartServer = async ( + serverConfig: ServerConfig, + typeDefs: TypeSource, + resolvers: any, + service: Service +): Promise => { + const { host, port, gqlPath = '/graphql' } = serverConfig + const { appOriginUrl, secret, domain, trustProxy } = serverConfig.session + + const app = express() + + // Create HTTP server + const httpServer = createServer(app) + + // Create the schema + const schema = makeExecutableSchema({ + typeDefs, + resolvers + }) + + const server = new ApolloServer({ + schema, + csrfPrevention: true, + context: async ({ req }) => { + // https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization + + const { address } = req.session + + if (!address) { + throw new AuthenticationError('Unauthorized: No active session') + } + + const user = await service.getUser(address) + return { user } + }, + plugins: [ + // Proper shutdown for the HTTP server + ApolloServerPluginDrainHttpServer({ httpServer }), + ApolloServerPluginLandingPageLocalDefault({ embed: true }) + ] + }) + + await server.start() + + app.use( + cors({ + origin: appOriginUrl, + credentials: true + }) + ) + + const sessionOptions: session.SessionOptions = { + secret: secret, + resave: false, + saveUninitialized: true, + cookie: { + secure: new URL(appOriginUrl).protocol === 'https:', + maxAge: COOKIE_MAX_AGE, + domain: domain || undefined, + sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax' + } + } + + if (trustProxy) { + // trust first proxy + app.set('trust proxy', 1) + } + + app.use(session(sessionOptions)) + + server.applyMiddleware({ + app: app as any, + path: gqlPath, + cors: { + origin: [appOriginUrl], + credentials: true + } + }) + + app.use(express.json()) + + app.set('service', service) + app.use('/auth', authRouter) + app.use('/api/github', githubRouter) + app.use('/staging', stagingRouter) + + app.use((err: any, _req: any, res: any, _next: any) => { + console.error(err) + res.status(500).json({ error: err.message }) + }) + + httpServer.listen(port, host, () => { + log(`Server is listening on ${host}:${port}${server.graphqlPath}`) + }) + + return server +} diff --git a/apps/backend/src/service.ts b/apps/backend/src/service.ts new file mode 100644 index 0000000..d7c1f26 --- /dev/null +++ b/apps/backend/src/service.ts @@ -0,0 +1,1783 @@ +import assert from 'node:assert' +import debug from 'debug' +import { DateTime } from 'luxon' + +import { Octokit, RequestError } from 'octokit' +import type { DeepPartial, FindOptionsWhere } from 'typeorm' + +import type { OAuthApp } from '@octokit/oauth-app' + +import type { GitHubConfig, RegistryConfig } from './config' +import type { Database } from './database' +import { Deployer } from './entity/Deployer' +import { + type ApplicationRecord, + type Deployment, + DeploymentStatus, + Environment +} from './entity/Deployment' +import type { Domain } from './entity/Domain' +import type { EnvironmentVariable } from './entity/EnvironmentVariable' +import type { Organization } from './entity/Organization' +import { Project } from './entity/Project' +import type { Permission, ProjectMember } from './entity/ProjectMember' +import { User } from './entity/User' +import { Role } from './entity/UserOrganization' +import type { Registry } from './registry' +import type { + AddProjectFromTemplateInput, + AppDeploymentRecord, + AppDeploymentRemovalRecord, + AuctionParams, + DNSRecord, + DNSRecordAttributes, + DeployerRecord, + EnvironmentVariables, + GitPushEventPayload +} from './types' +import { getRepoDetails } from './utils' + +const log = debug('snowball:service') + +const GITHUB_UNIQUE_WEBHOOK_ERROR = 'Hook already exists on this repository' + +// Define a constant for an hour in milliseconds +const HOUR = 1000 * 60 * 60 + +interface Config { + gitHubConfig: GitHubConfig + registryConfig: RegistryConfig +} + +export class Service { + private db: Database + private oauthApp: OAuthApp + private laconicRegistry: Registry + private config: Config + + private deployRecordCheckTimeout?: NodeJS.Timeout + private auctionStatusCheckTimeout?: NodeJS.Timeout + + constructor(config: Config, db: Database, app: OAuthApp, registry: Registry) { + this.db = db + this.oauthApp = app + this.laconicRegistry = registry + this.config = config + this.init() + } + + /** + * Initialize services + */ + init(): void { + // Start check for ApplicationDeploymentRecords asynchronously + this.checkDeployRecordsAndUpdate() + // Start check for ApplicationDeploymentRemovalRecords asynchronously + this.checkDeploymentRemovalRecordsAndUpdate() + // Start check for Deployment Auctions asynchronously + this.checkAuctionStatus() + } + + /** + * Destroy services + */ + destroy(): void { + clearTimeout(this.deployRecordCheckTimeout) + clearTimeout(this.auctionStatusCheckTimeout) + } + + /** + * Checks for ApplicationDeploymentRecord and update corresponding deployments + * Continues check in loop after a delay of registryConfig.fetchDeploymentRecordDelay + */ + async checkDeployRecordsAndUpdate(): Promise { + // Fetch deployments in building state + const deployments = await this.db.getDeployments({ + where: { + status: DeploymentStatus.Building + } + }) + + if (deployments.length) { + log( + `Found ${deployments.length} deployments in ${DeploymentStatus.Building} state` + ) + + // Calculate a timestamp for one hour ago + const anHourAgo = Date.now() - HOUR + + // Filter out deployments started more than an hour ago and mark them as Error + const oldDeploymentsToUpdate = deployments + .filter((deployment) => Number(deployment.updatedAt) < anHourAgo) + .map((deployment) => { + return this.db.updateDeploymentById(deployment.id, { + status: DeploymentStatus.Error, + isCurrent: false + }) + }) + + // If there are old deployments to update, log and perform the updates + if (oldDeploymentsToUpdate.length > 0) { + log( + `Cleaning up ${oldDeploymentsToUpdate.length} deployments stuck in ${DeploymentStatus.Building} state for over an hour` + ) + await Promise.all(oldDeploymentsToUpdate) + } + + // Fetch ApplicationDeploymentRecord for deployments + const records = + await this.laconicRegistry.getDeploymentRecords(deployments) + log(`Found ${records.length} ApplicationDeploymentRecords`) + + // Update deployments for which ApplicationDeploymentRecords were returned + if (records.length) { + await this.updateDeploymentsWithRecordData(records) + } + } + + this.deployRecordCheckTimeout = setTimeout(() => { + this.checkDeployRecordsAndUpdate() + }, this.config.registryConfig.fetchDeploymentRecordDelay) + } + + /** + * Checks for ApplicationDeploymentRemovalRecord and remove corresponding deployments + * Continues check in loop after a delay of registryConfig.fetchDeploymentRecordDelay + */ + async checkDeploymentRemovalRecordsAndUpdate(): Promise { + // Fetch deployments in deleting state + const deployments = await this.db.getDeployments({ + where: { + status: DeploymentStatus.Deleting + } + }) + + if (deployments.length) { + log( + `Found ${deployments.length} deployments in ${DeploymentStatus.Deleting} state` + ) + + // Fetch ApplicationDeploymentRemovalRecords for deployments + const records = + await this.laconicRegistry.getDeploymentRemovalRecords(deployments) + log(`Found ${records.length} ApplicationDeploymentRemovalRecords`) + + // Update deployments for which ApplicationDeploymentRemovalRecords were returned + if (records.length) { + await this.deleteDeploymentsWithRecordData(records, deployments) + } + } + + this.deployRecordCheckTimeout = setTimeout(() => { + this.checkDeploymentRemovalRecordsAndUpdate() + }, this.config.registryConfig.fetchDeploymentRecordDelay) + } + + /** + * Update deployments with ApplicationDeploymentRecord data + * Deployments that are completed but not updated in DB + */ + async updateDeploymentsWithRecordData( + records: AppDeploymentRecord[] + ): Promise { + // Fetch the deployments to be updated using deployment requestId + const deployments = await this.db.getDeployments({ + where: records.map((record) => ({ + applicationDeploymentRequestId: record.attributes.request + })), + relations: { + deployer: true, + project: true + }, + order: { + createdAt: 'DESC' + } + }) + + const recordToDeploymentsMap = deployments.reduce( + (acc: { [key: string]: Deployment }, deployment) => { + if (deployment.applicationDeploymentRequestId) { + acc[deployment.applicationDeploymentRequestId] = deployment + } + return acc + }, + {} + ) + + // Update deployment data for ApplicationDeploymentRecords + const deploymentUpdatePromises = records.map(async (record) => { + const deployment = recordToDeploymentsMap[record.attributes.request] + + if (!deployment.project) { + log(`Project ${deployment.projectId} not found`) + return + } + + const registryRecord = await this.laconicRegistry.getRecordById( + record.attributes.dns + ) + + if (!registryRecord) { + log(`DNS record not found for deployment ${deployment.id}`) + return + } + + const dnsRecord = registryRecord as DNSRecord + + const dnsRecordData: DNSRecordAttributes = { + name: dnsRecord.attributes.name, + request: dnsRecord.attributes.request, + resourceType: dnsRecord.attributes.resource_type, + value: dnsRecord.attributes.value, + version: dnsRecord.attributes.version + } + + deployment.applicationDeploymentRecordId = record.id + deployment.applicationDeploymentRecordData = record.attributes + deployment.url = record.attributes.url + deployment.status = DeploymentStatus.Ready + deployment.isCurrent = deployment.environment === Environment.Production + deployment.dnsRecordData = dnsRecordData + + if (deployment.isCanonical) { + const previousCanonicalDeployment = await this.db.getDeployment({ + where: { + projectId: deployment.project.id, + deployer: deployment.deployer, + isCanonical: true, + isCurrent: true + }, + relations: { + project: true, + deployer: true + } + }) + + if (previousCanonicalDeployment) { + // Send removal request for the previous canonical deployment and delete DB entry + if (previousCanonicalDeployment.url !== deployment.url) { + await this.laconicRegistry.createApplicationDeploymentRemovalRequest( + { + deploymentId: + previousCanonicalDeployment.applicationDeploymentRecordId!, + deployerLrn: previousCanonicalDeployment.deployer.deployerLrn, + auctionId: previousCanonicalDeployment.project.auctionId, + payment: previousCanonicalDeployment.project.txHash + } + ) + } + + await this.db.deleteDeploymentById(previousCanonicalDeployment.id) + } + } + + await this.db.updateDeploymentById(deployment.id, deployment) + + // Release deployer funds on successful deployment + if (!deployment.project.fundsReleased) { + const fundsReleased = await this.releaseDeployerFundsByProjectId( + deployment.projectId + ) + + // Return remaining amount to owner + await this.returnUserFundsByProjectId(deployment.projectId, true) + + await this.db.updateProjectById(deployment.projectId, { + fundsReleased + }) + } + + log( + `Updated deployment ${deployment.id} with URL ${record.attributes.url}` + ) + }) + + await Promise.all(deploymentUpdatePromises) + + // Get deployments that are in production environment + const prodDeployments = Object.values(recordToDeploymentsMap).filter( + (deployment) => deployment.isCurrent + ) + // Set the isCurrent state to false for the old deployments + for (const deployment of prodDeployments) { + const projectDeployments = await this.db.getDeploymentsByProjectId( + deployment.projectId + ) + + const oldDeployments = projectDeployments.filter( + (projectDeployment) => + projectDeployment.deployer.deployerLrn === + deployment.deployer.deployerLrn && + projectDeployment.id !== deployment.id && + projectDeployment.isCanonical === deployment.isCanonical + ) + for (const oldDeployment of oldDeployments) { + await this.db.updateDeployment( + { id: oldDeployment.id }, + { isCurrent: false } + ) + } + } + } + + /** + * Delete deployments with ApplicationDeploymentRemovalRecord data + */ + async deleteDeploymentsWithRecordData( + records: AppDeploymentRemovalRecord[], + deployments: Deployment[] + ): Promise { + const removedApplicationDeploymentRecordIds = records.map( + (record) => record.attributes.deployment + ) + + // Get removed deployments for ApplicationDeploymentRecords + const removedDeployments = deployments.filter((deployment) => + removedApplicationDeploymentRecordIds.includes( + deployment.applicationDeploymentRecordId! + ) + ) + + const recordToDeploymentsMap = removedDeployments.reduce( + (acc: { [key: string]: Deployment }, deployment) => { + if (deployment.applicationDeploymentRecordId) { + acc[deployment.applicationDeploymentRecordId] = deployment + } + return acc + }, + {} + ) + + // Update deployment data for ApplicationDeploymentRecords and delete + const deploymentUpdatePromises = records.map(async (record) => { + const deployment = recordToDeploymentsMap[record.attributes.deployment] + + await this.db.updateDeploymentById(deployment.id, { + applicationDeploymentRemovalRecordId: record.id, + applicationDeploymentRemovalRecordData: record.attributes + }) + + log( + `Updated deployment ${deployment.id} with ApplicationDeploymentRemovalRecord ${record.id}` + ) + + await this.db.deleteDeploymentById(deployment.id) + }) + + await Promise.all(deploymentUpdatePromises) + } + + /** + * Checks the status for all ongoing auctions + * Calls the createDeploymentFromAuction method for deployments with completed auctions + */ + async checkAuctionStatus(): Promise { + const projects = await this.db.allProjectsWithoutDeployments() + + const validAuctionIds = projects + .map((project) => project.auctionId) + .filter((id): id is string => Boolean(id)) + const completedAuctionIds = + await this.laconicRegistry.getCompletedAuctionIds(validAuctionIds) + + const projectsToBedeployed = projects.filter( + (project) => + project.auctionId && completedAuctionIds.includes(project.auctionId) + ) + + for (const project of projectsToBedeployed) { + const deployerRecords = + await this.laconicRegistry.getAuctionWinningDeployerRecords( + project!.auctionId! + ) + + if (!deployerRecords) { + log(`No winning deployer for auction ${project!.auctionId}`) + + // Return all funds to the owner + await this.returnUserFundsByProjectId(project.id, false) + } else { + const deployers = + await this.saveDeployersByDeployerRecords(deployerRecords) + for (const deployer of deployers) { + log(`Creating deployment for deployer ${deployer.deployerLrn}`) + await this.createDeploymentFromAuction(project, deployer) + // Update project with deployer + await this.updateProjectWithDeployer(project.id, deployer) + } + } + } + + this.auctionStatusCheckTimeout = setTimeout(() => { + this.checkAuctionStatus() + }, this.config.registryConfig.checkAuctionStatusDelay) + } + + async getUser(userId: string): Promise { + return this.db.getUser({ + where: { + id: userId + } + }) + } + + async getUserByEmail(email: string): Promise { + return await this.db.getUser({ + where: { + email + } + }) + } + + async getUserBySubOrgId(subOrgId: string): Promise { + return await this.db.getUser({ + where: { + subOrgId + } + }) + } + + async getUserByEthAddress(ethAddress: string): Promise { + return await this.db.getUser({ + where: { + ethAddress + } + }) + } + + async createUser(params: { + name?: string + email: string + subOrgId: string + ethAddress: string + turnkeyWalletId: string + }): Promise { + const [org] = await this.db.getOrganizations({}) + assert(org, 'No organizations exists in database') + + // Create user with new address + const user = await this.db.addUser({ + email: params.email, + name: params.name, + subOrgId: params.subOrgId, + ethAddress: params.ethAddress, + isVerified: true, + turnkeyWalletId: params.turnkeyWalletId + }) + + await this.db.addUserOrganization({ + member: user, + organization: org, + role: Role.Owner + }) + + return user + } + + async getOctokit(userId: string): Promise { + const user = await this.db.getUser({ where: { id: userId } }) + assert( + user?.gitHubToken, + 'User needs to be authenticated with GitHub token' + ) + + return new Octokit({ auth: user.gitHubToken }) + } + + async getOrganizationsByUserId(user: User): Promise { + const dbOrganizations = await this.db.getOrganizationsByUserId(user.id) + return dbOrganizations + } + + async getProjectById(user: User, projectId: string): Promise { + const dbProject = await this.db.getProjectById(projectId) + + if (dbProject && dbProject.owner.id !== user.id) { + return null + } + + return dbProject + } + + async getProjectsInOrganization( + user: User, + organizationSlug: string + ): Promise { + const dbProjects = await this.db.getProjectsInOrganization( + user.id, + organizationSlug + ) + return dbProjects + } + + async getNonCanonicalDeploymentsByProjectId( + projectId: string + ): Promise { + const nonCanonicalDeployments = + await this.db.getNonCanonicalDeploymentsByProjectId(projectId) + return nonCanonicalDeployments + } + + async getLatestDNSRecordByProjectId( + projectId: string + ): Promise { + const dnsRecord = await this.db.getLatestDNSRecordByProjectId(projectId) + return dnsRecord + } + + async getEnvironmentVariablesByProjectId( + projectId: string + ): Promise { + const dbEnvironmentVariables = + await this.db.getEnvironmentVariablesByProjectId(projectId) + return dbEnvironmentVariables + } + + async getProjectMembersByProjectId( + projectId: string + ): Promise { + const dbProjectMembers = + await this.db.getProjectMembersByProjectId(projectId) + return dbProjectMembers + } + + async searchProjects(user: User, searchText: string): Promise { + const dbProjects = await this.db.getProjectsBySearchText( + user.id, + searchText + ) + return dbProjects + } + + async getDomainsByProjectId( + projectId: string, + filter?: FindOptionsWhere + ): Promise { + const dbDomains = await this.db.getDomainsByProjectId(projectId, filter) + return dbDomains + } + + async updateProjectMember( + projectMemberId: string, + data: { permissions: Permission[] } + ): Promise { + return this.db.updateProjectMemberById(projectMemberId, data) + } + + async addProjectMember( + projectId: string, + data: { + email: string + permissions: Permission[] + } + ): Promise { + // TODO: Send invitation + let user = await this.db.getUser({ + where: { + email: data.email + } + }) + + if (!user) { + user = await this.db.addUser({ + email: data.email + }) + } + + const newProjectMember = await this.db.addProjectMember({ + project: { + id: projectId + }, + permissions: data.permissions, + isPending: true, + member: { + id: user.id + } + }) + + return newProjectMember + } + + async removeProjectMember( + user: User, + projectMemberId: string + ): Promise { + const member = await this.db.getProjectMemberById(projectMemberId) + + if (String(member.member.id) === user.id) { + throw new Error('Invalid operation: cannot remove self') + } + + const memberProject = member.project + assert(memberProject) + + if (String(user.id) === String(memberProject.owner.id)) { + return this.db.removeProjectMemberById(projectMemberId) + } + + throw new Error('Invalid operation: not authorized') + } + + async addEnvironmentVariables( + projectId: string, + data: { environments: string[]; key: string; value: string }[] + ): Promise { + const formattedEnvironmentVariables = data.flatMap( + (environmentVariable) => { + return environmentVariable.environments.map((environment) => { + return { + key: environmentVariable.key, + value: environmentVariable.value, + environment: environment as Environment, + project: Object.assign(new Project(), { + id: projectId + }) + } + }) + } + ) + + const savedEnvironmentVariables = await this.db.addEnvironmentVariables( + formattedEnvironmentVariables + ) + return savedEnvironmentVariables + } + + async updateEnvironmentVariable( + environmentVariableId: string, + data: DeepPartial + ): Promise { + return this.db.updateEnvironmentVariable(environmentVariableId, data) + } + + async removeEnvironmentVariable( + environmentVariableId: string + ): Promise { + return this.db.deleteEnvironmentVariable(environmentVariableId) + } + + async updateDeploymentToProd( + user: User, + deploymentId: string + ): Promise { + const oldDeployment = await this.db.getDeployment({ + where: { id: deploymentId }, + relations: { + project: true, + deployer: true + } + }) + + if (!oldDeployment) { + throw new Error('Deployment does not exist') + } + + const octokit = await this.getOctokit(user.id) + + const newDeployment = await this.createDeployment(user.id, octokit, { + project: oldDeployment.project, + branch: oldDeployment.branch, + environment: Environment.Production, + commitHash: oldDeployment.commitHash, + commitMessage: oldDeployment.commitMessage, + deployer: oldDeployment.deployer + }) + + return newDeployment + } + + async createDeployment( + userId: string, + octokit: Octokit, + data: DeepPartial, + deployerLrn?: string + ): Promise { + assert(data.project?.repository, 'Project repository not found') + log( + `Creating deployment in project ${data.project.name} from branch ${data.branch}` + ) + + // TODO: Set environment variables for each deployment (environment variables can`t be set in application record) + const { applicationRecordId, applicationRecordData } = + await this.laconicRegistry.createApplicationRecord({ + octokit, + repository: data.project.repository, + appType: data.project!.template!, + commitHash: data.commitHash! + }) + + let deployer: Deployer | undefined + if (deployerLrn) { + const found = await this.db.getDeployerByLRN(deployerLrn) + deployer = found || undefined + } else { + deployer = data.deployer as Deployer + } + + const deployment = await this.createDeploymentFromData( + userId, + data, + deployer!.deployerLrn!, + applicationRecordId, + applicationRecordData, + false + ) + + const address = await this.getAddress() + const { repo, repoUrl } = await getRepoDetails( + octokit, + data.project.repository, + data.commitHash + ) + const environmentVariablesObj = await this.getEnvVariables( + data.project!.id! + ) + + // To set project DNS + if (data.environment === Environment.Production) { + const canonicalDeployment = await this.createDeploymentFromData( + userId, + data, + deployer!.deployerLrn!, + applicationRecordId, + applicationRecordData, + true + ) + // If a custom domain is present then use that as the DNS in the deployment request + const customDomain = await this.db.getOldestDomainByProjectId( + data.project!.id! + ) + + // On deleting deployment later, project canonical deployment is also deleted + // So publish project canonical deployment first so that ApplicationDeploymentRecord for the same is available when deleting deployment later + const { + applicationDeploymentRequestData, + applicationDeploymentRequestId + } = await this.laconicRegistry.createApplicationDeploymentRequest({ + deployment: canonicalDeployment, + appName: repo, + repository: repoUrl, + environmentVariables: environmentVariablesObj, + dns: customDomain?.name ?? `${canonicalDeployment.project.name}`, + lrn: deployer!.deployerLrn!, + apiUrl: deployer!.deployerApiUrl!, + payment: data.project.txHash, + auctionId: data.project.auctionId, + requesterAddress: address, + publicKey: deployer!.publicKey! + }) + + await this.db.updateDeploymentById(canonicalDeployment.id, { + applicationDeploymentRequestId, + applicationDeploymentRequestData + }) + } + + const { applicationDeploymentRequestId, applicationDeploymentRequestData } = + await this.laconicRegistry.createApplicationDeploymentRequest({ + deployment: deployment, + appName: repo, + repository: repoUrl, + lrn: deployer!.deployerLrn!, + apiUrl: deployer!.deployerApiUrl!, + environmentVariables: environmentVariablesObj, + dns: `${deployment.project.name}-${deployment.id}`, + payment: data.project.txHash, + auctionId: data.project.auctionId, + requesterAddress: address, + publicKey: deployer!.publicKey! + }) + + await this.db.updateDeploymentById(deployment.id, { + applicationDeploymentRequestId, + applicationDeploymentRequestData + }) + + return deployment + } + + async createDeploymentFromAuction( + project: DeepPartial, + deployer: Deployer + ): Promise { + const octokit = await this.getOctokit(project.ownerId!) + const [owner, repo] = project.repository!.split('/') + + const repoUrl = ( + await octokit.rest.repos.get({ + owner, + repo + }) + ).data.html_url + + const { + data: [latestCommit] + } = await octokit.rest.repos.listCommits({ + owner, + repo, + sha: project.prodBranch, + per_page: 1 + }) + + const lrn = this.laconicRegistry.getLrn(repo) + const [record] = await this.laconicRegistry.getRecordsByName(lrn) + const applicationRecordId = record.id + const applicationRecordData = record.attributes + + const deployerLrn = deployer!.deployerLrn + + // Create deployment with prod branch and latest commit + const deploymentData = { + project, + branch: project.prodBranch, + environment: Environment.Production, + domain: null, + commitHash: latestCommit.sha, + commitMessage: latestCommit.commit.message + } + + const deployment = await this.createDeploymentFromData( + project.ownerId!, + deploymentData, + deployerLrn, + applicationRecordId, + applicationRecordData, + false + ) + const address = await this.getAddress() + + const environmentVariablesObj = await this.getEnvVariables(project!.id!) + // To set project DNS + if (deploymentData.environment === Environment.Production) { + const canonicalDeployment = await this.createDeploymentFromData( + project.ownerId!, + deploymentData, + deployerLrn, + applicationRecordId, + applicationRecordData, + true + ) + // If a custom domain is present then use that as the DNS in the deployment request + const customDomain = await this.db.getOldestDomainByProjectId( + project!.id! + ) + + // On deleting deployment later, project canonical deployment is also deleted + // So publish project canonical deployment first so that ApplicationDeploymentRecord for the same is available when deleting deployment later + const { + applicationDeploymentRequestId, + applicationDeploymentRequestData + } = await this.laconicRegistry.createApplicationDeploymentRequest({ + deployment: canonicalDeployment, + appName: repo, + repository: repoUrl, + environmentVariables: environmentVariablesObj, + dns: customDomain?.name ?? `${canonicalDeployment.project.name}`, + auctionId: project.auctionId!, + lrn: deployerLrn, + apiUrl: deployer!.deployerApiUrl!, + requesterAddress: address, + publicKey: deployer!.publicKey! + }) + + await this.db.updateDeploymentById(canonicalDeployment.id, { + applicationDeploymentRequestId, + applicationDeploymentRequestData + }) + } + + const { applicationDeploymentRequestId, applicationDeploymentRequestData } = + // Create requests for all the deployers + await this.laconicRegistry.createApplicationDeploymentRequest({ + deployment: deployment, + appName: repo, + repository: repoUrl, + auctionId: project.auctionId!, + lrn: deployerLrn, + apiUrl: deployer!.deployerApiUrl!, + environmentVariables: environmentVariablesObj, + dns: `${deployment.project.name}-${deployment.id}`, + requesterAddress: address, + publicKey: deployer!.publicKey! + }) + + await this.db.updateDeploymentById(deployment.id, { + applicationDeploymentRequestId, + applicationDeploymentRequestData + }) + + return deployment + } + + async createDeploymentFromData( + userId: string, + data: DeepPartial, + deployerLrn: string, + applicationRecordId: string, + applicationRecordData: ApplicationRecord, + isCanonical: boolean + ): Promise { + const newDeployment = await this.db.addDeployment({ + project: data.project, + branch: data.branch, + commitHash: data.commitHash, + commitMessage: data.commitMessage, + environment: data.environment, + status: DeploymentStatus.Building, + applicationRecordId, + applicationRecordData, + createdBy: Object.assign(new User(), { + id: userId + }), + deployer: Object.assign(new Deployer(), { + deployerLrn + }), + isCanonical + }) + + log(`Created deployment ${newDeployment.id}`) + + return newDeployment + } + + async updateProjectWithDeployer( + projectId: string, + deployer: Deployer + ): Promise { + const deploymentProject = await this.db.getProjects({ + where: { id: projectId }, + relations: ['deployers'] + }) + + if (!deploymentProject[0].deployers) { + deploymentProject[0].deployers = [] + } + + deploymentProject[0].deployers.push(deployer) + + await this.db.saveProject(deploymentProject[0]) + + return deployer + } + + async addProjectFromTemplate( + user: User, + organizationSlug: string, + data: AddProjectFromTemplateInput, + lrn?: string, + auctionParams?: AuctionParams, + environmentVariables?: EnvironmentVariables[] + ): Promise { + try { + const octokit = await this.getOctokit(user.id) + + const gitRepo = await octokit?.rest.repos.createUsingTemplate({ + template_owner: data.templateOwner, + template_repo: data.templateRepo, + owner: data.owner, + name: data.name, + include_all_branches: false, + private: data.isPrivate + }) + + if (!gitRepo) { + throw new Error('Failed to create repository from template') + } + + const createdTemplateRepo = await octokit.rest.repos.get({ + owner: data.owner, + repo: data.name + }) + + const prodBranch = createdTemplateRepo.data.default_branch ?? 'main' + + const project = await this.addProject( + user, + organizationSlug, + { + name: `${gitRepo.data.owner!.login}-${gitRepo.data.name}`, + prodBranch, + repository: gitRepo.data.full_name, + // TODO: Set selected template + template: 'webapp', + paymentAddress: data.paymentAddress, + txHash: data.txHash + }, + lrn, + auctionParams, + environmentVariables + ) + + if (!project || !project.id) { + throw new Error('Failed to create project from template') + } + + return project + } catch (error) { + console.error('Error creating project from template:', error) + throw error + } + } + + async addProject( + user: User, + organizationSlug: string, + data: DeepPartial, + lrn?: string, + auctionParams?: AuctionParams, + environmentVariables?: EnvironmentVariables[] + ): Promise { + const organization = await this.db.getOrganization({ + where: { + slug: organizationSlug + } + }) + + if (!organization) { + throw new Error('Organization does not exist') + } + + const project = await this.db.addProject(user, organization.id, data) + + if (environmentVariables) { + await this.addEnvironmentVariables(project.id, environmentVariables) + } + + const octokit = await this.getOctokit(user.id) + const [owner, repo] = project.repository.split('/') + + const { + data: [latestCommit] + } = await octokit.rest.repos.listCommits({ + owner, + repo, + sha: project.prodBranch, + per_page: 1 + }) + + if (auctionParams) { + // Create deployment with prod branch and latest commit + const deploymentData = { + project, + branch: project.prodBranch, + environment: Environment.Production, + domain: null, + commitHash: latestCommit.sha, + commitMessage: latestCommit.commit.message + } + const { applicationDeploymentAuctionId } = + await this.laconicRegistry.createApplicationDeploymentAuction( + repo, + octokit, + auctionParams!, + deploymentData + ) + await this.updateProject(project.id, { + auctionId: applicationDeploymentAuctionId + }) + } else { + const deployer = await this.db.getDeployerByLRN(lrn!) + + if (!deployer) { + log('Invalid deployer LRN') + return + } + + if (deployer.minimumPayment && project.txHash) { + const amountToBePaid = deployer?.minimumPayment + .replace(/\D/g, '') + .toString() + + const txResponse = await this.laconicRegistry.sendTokensToAccount( + deployer?.paymentAddress!, + amountToBePaid + ) + + const txHash = txResponse.transactionHash + if (txHash) { + await this.updateProject(project.id, { txHash }) + project.txHash = txHash + log('Funds transferrend to deployer') + } + } + + const deploymentData = { + project, + branch: project.prodBranch, + environment: Environment.Production, + domain: null, + commitHash: latestCommit.sha, + commitMessage: latestCommit.commit.message, + deployer + } + + const newDeployment = await this.createDeployment( + user.id, + octokit, + deploymentData + ) + // Update project with deployer + await this.updateProjectWithDeployer( + newDeployment.projectId, + newDeployment.deployer + ) + } + + await this.createRepoHook(octokit, project) + + return project + } + + async createRepoHook(octokit: Octokit, project: Project): Promise { + try { + const [owner, repo] = project.repository.split('/') + await octokit.rest.repos.createWebhook({ + owner, + repo, + config: { + url: new URL( + 'api/github/webhook', + this.config.gitHubConfig.webhookUrl + ).href, + content_type: 'json' + }, + events: ['push'] + }) + } catch (err) { + // https://docs.github.com/en/rest/repos/webhooks?apiVersion=2022-11-28#create-a-repository-webhook--status-codes + if ( + !( + err instanceof RequestError && + err.status === 422 && + (err.response?.data as any).errors.some( + (err: any) => err.message === GITHUB_UNIQUE_WEBHOOK_ERROR + ) + ) + ) { + throw err + } + + log(GITHUB_UNIQUE_WEBHOOK_ERROR) + } + } + + async handleGitHubPush(data: GitPushEventPayload): Promise { + const { repository, ref, head_commit: headCommit, deleted } = data + + if (deleted) { + log(`Branch ${ref} deleted for project ${repository.full_name}`) + return + } + + log( + `Handling GitHub push event from repository: ${repository.full_name}, branch: ${ref}` + ) + const projects = await this.db.getProjects({ + where: { repository: repository.full_name }, + relations: { + deployers: true + } + }) + + if (!projects.length) { + log(`No projects found for repository ${repository.full_name}`) + } + + // The `ref` property contains the full reference, including the branch name + // For example, "refs/heads/main" or "refs/heads/feature-branch" + const branch = ref.split('/').pop() + + for await (const project of projects) { + const octokit = await this.getOctokit(project.ownerId) + + const deployers = project.deployers + if (!deployers) { + log(`No deployer present for project ${project.id}`) + return + } + + for (const deployer of deployers) { + // Create deployment with branch and latest commit in GitHub data + await this.createDeployment(project.ownerId, octokit, { + project, + branch, + environment: + project.prodBranch === branch + ? Environment.Production + : Environment.Preview, + commitHash: headCommit.id, + commitMessage: headCommit.message, + deployer: deployer + }) + } + } + } + + async updateProject( + projectId: string, + data: DeepPartial + ): Promise { + return this.db.updateProjectById(projectId, data) + } + + async deleteProject(projectId: string): Promise { + // TODO: Remove GitHub repo hook + return this.db.deleteProjectById(projectId) + } + + async deleteDomain(domainId: string): Promise { + const domainsRedirectedFrom = await this.db.getDomains({ + where: { + redirectToId: domainId + } + }) + + if (domainsRedirectedFrom.length > 0) { + throw new Error( + 'Cannot delete domain since it has redirects from other domains' + ) + } + + return this.db.deleteDomainById(domainId) + } + + async redeployToProd(user: User, deploymentId: string): Promise { + const oldDeployment = await this.db.getDeployment({ + relations: { + project: true, + deployer: true, + createdBy: true + }, + where: { + id: deploymentId + } + }) + + if (oldDeployment === null) { + throw new Error('Deployment not found') + } + + const octokit = await this.getOctokit(user.id) + + let newDeployment: Deployment + + if (oldDeployment.project.auctionId) { + newDeployment = await this.createDeploymentFromAuction( + oldDeployment.project, + oldDeployment.deployer + ) + } else { + newDeployment = await this.createDeployment(user.id, octokit, { + project: oldDeployment.project, + // TODO: Put isCurrent field in project + branch: oldDeployment.branch, + environment: Environment.Production, + commitHash: oldDeployment.commitHash, + commitMessage: oldDeployment.commitMessage, + deployer: oldDeployment.deployer + }) + } + + return newDeployment + } + + async rollbackDeployment( + projectId: string, + deploymentId: string + ): Promise { + // TODO: Implement transactions + const oldCurrentDeployment = await this.db.getDeployment({ + relations: { + project: true, + deployer: true + }, + where: { + project: { + id: projectId + }, + isCurrent: true, + isCanonical: false + } + }) + + if (!oldCurrentDeployment) { + throw new Error('Current deployment does not exist') + } + + const oldCurrentDeploymentUpdate = await this.db.updateDeploymentById( + oldCurrentDeployment.id, + { isCurrent: false } + ) + + const newCurrentDeploymentUpdate = await this.db.updateDeploymentById( + deploymentId, + { isCurrent: true } + ) + + if (!newCurrentDeploymentUpdate || !oldCurrentDeploymentUpdate) { + return false + } + + const newCurrentDeployment = await this.db.getDeployment({ + where: { id: deploymentId }, + relations: { project: true, deployer: true } + }) + + if (!newCurrentDeployment) { + throw new Error(`Deployment with Id ${deploymentId} not found`) + } + + const applicationDeploymentRequestData = + newCurrentDeployment.applicationDeploymentRequestData + + const customDomain = await this.db.getOldestDomainByProjectId(projectId) + + if (customDomain && applicationDeploymentRequestData) { + applicationDeploymentRequestData.dns = customDomain.name + } + + // Create a canonical deployment for the new current deployment + const canonicalDeployment = await this.createDeploymentFromData( + newCurrentDeployment.project.ownerId, + newCurrentDeployment, + newCurrentDeployment.deployer!.deployerLrn!, + newCurrentDeployment.applicationRecordId, + newCurrentDeployment.applicationRecordData, + true + ) + + applicationDeploymentRequestData!.meta = JSON.stringify({ + ...JSON.parse(applicationDeploymentRequestData!.meta), + note: `Updated by Snowball @ ${DateTime.utc().toFormat( + "EEE LLL dd HH:mm:ss 'UTC' yyyy" + )}` + }) + + const result = await this.laconicRegistry.publishRecord( + applicationDeploymentRequestData + ) + + log(`Application deployment request record published: ${result.id}`) + + const updateResult = await this.db.updateDeploymentById( + canonicalDeployment.id, + { + applicationDeploymentRequestId: result.id, + applicationDeploymentRequestData + } + ) + + return updateResult + } + + async deleteDeployment(deploymentId: string): Promise { + const deployment = await this.db.getDeployment({ + where: { + id: deploymentId + }, + relations: { + project: true, + deployer: true + } + }) + + if (deployment?.applicationDeploymentRecordId) { + // If deployment is current, remove deployment for project subdomain as well + if (deployment.isCurrent) { + const canonicalDeployment = await this.db.getDeployment({ + where: { + projectId: deployment.project.id, + deployer: deployment.deployer, + isCanonical: true + }, + relations: { + project: true, + deployer: true + } + }) + + // If the canonical deployment is not present then query the chain for the deployment record for backward compatibility + if (!canonicalDeployment) { + log( + `Canonical deployment for deployment with id ${deployment.id} not found, querying the chain..` + ) + const currentDeploymentURL = `https://${(deployment.project.name).toLowerCase()}.${deployment.deployer.baseDomain}` + + const deploymentRecords = + await this.laconicRegistry.getDeploymentRecordsByFilter({ + application: deployment.applicationRecordId, + url: currentDeploymentURL + }) + + if (!deploymentRecords.length) { + log( + `No ApplicationDeploymentRecord found for URL ${currentDeploymentURL} and ApplicationDeploymentRecord id ${deployment.applicationDeploymentRecordId}` + ) + + return false + } + + // Multiple records are fetched, take the latest record + const latestRecord = deploymentRecords.sort( + (a, b) => + new Date(b.createTime).getTime() - + new Date(a.createTime).getTime() + )[0] + + await this.laconicRegistry.createApplicationDeploymentRemovalRequest({ + deploymentId: latestRecord.id, + deployerLrn: deployment.deployer.deployerLrn, + auctionId: deployment.project.auctionId, + payment: deployment.project.txHash + }) + } else { + // If canonical deployment is found in the DB, then send the removal request with that deployment record Id + const result = + await this.laconicRegistry.createApplicationDeploymentRemovalRequest( + { + deploymentId: + canonicalDeployment.applicationDeploymentRecordId!, + deployerLrn: canonicalDeployment.deployer.deployerLrn, + auctionId: canonicalDeployment.project.auctionId, + payment: canonicalDeployment.project.txHash + } + ) + + await this.db.updateDeploymentById(canonicalDeployment.id, { + status: DeploymentStatus.Deleting, + applicationDeploymentRemovalRequestId: + result.applicationDeploymentRemovalRequestId, + applicationDeploymentRemovalRequestData: + result.applicationDeploymentRemovalRequestData + }) + } + } + + const result = + await this.laconicRegistry.createApplicationDeploymentRemovalRequest({ + deploymentId: deployment.applicationDeploymentRecordId, + deployerLrn: deployment.deployer.deployerLrn, + auctionId: deployment.project.auctionId, + payment: deployment.project.txHash + }) + + await this.db.updateDeploymentById(deployment.id, { + status: DeploymentStatus.Deleting, + applicationDeploymentRemovalRequestId: + result.applicationDeploymentRemovalRequestId, + applicationDeploymentRemovalRequestData: + result.applicationDeploymentRemovalRequestData + }) + + return result !== undefined || result !== null + } + + return false + } + + async addDomain( + projectId: string, + data: { name: string } + ): Promise<{ + primaryDomain: Domain + // redirectedDomain: Domain; + }> { + const currentProject = await this.db.getProjectById(projectId) + + if (currentProject === null) { + throw new Error(`Project with ${projectId} not found`) + } + + const primaryDomainDetails = { + ...data, + branch: currentProject.prodBranch, + project: currentProject + } + + const savedPrimaryDomain = await this.db.addDomain(primaryDomainDetails) + + // const domainArr = data.name.split('www.'); + + // const redirectedDomainDetails = { + // name: domainArr.length > 1 ? domainArr[1] : `www.${domainArr[0]}`, + // branch: currentProject.prodBranch, + // project: currentProject, + // redirectTo: savedPrimaryDomain, + // }; + + // const savedRedirectedDomain = await this.db.addDomain( + // redirectedDomainDetails, + // ); + + return { + primaryDomain: savedPrimaryDomain + // redirectedDomain: savedRedirectedDomain, + } + } + + async updateDomain( + domainId: string, + data: DeepPartial + ): Promise { + const domain = await this.db.getDomain({ + where: { + id: domainId + } + }) + + if (domain === null) { + throw new Error(`Error finding domain with id ${domainId}`) + } + + const newDomain = { + ...data + } + + const domainsRedirectedFrom = await this.db.getDomains({ + where: { + project: { + id: domain.projectId + }, + redirectToId: domain.id + } + }) + + // If there are domains redirecting to current domain, only branch of current domain can be updated + if (domainsRedirectedFrom.length > 0 && data.branch === domain.branch) { + throw new Error('Remove all redirects to this domain before updating') + } + + if (data.redirectToId) { + const redirectedDomain = await this.db.getDomain({ + where: { + id: data.redirectToId + } + }) + + if (redirectedDomain === null) { + throw new Error('Could not find Domain to redirect to') + } + + if (redirectedDomain.redirectToId) { + throw new Error( + 'Unable to redirect to the domain because it is already redirecting elsewhere. Redirects cannot be chained.' + ) + } + + newDomain.redirectTo = redirectedDomain + } + + const updateResult = await this.db.updateDomainById(domainId, newDomain) + + return updateResult + } + + async authenticateGitHub( + code: string, + user: User + ): Promise<{ token: string }> { + const { + authentication: { token } + } = await this.oauthApp.createToken({ + code + }) + + await this.db.updateUser(user, { gitHubToken: token }) + + return { token } + } + + async unauthenticateGitHub( + user: User, + data: DeepPartial + ): Promise { + return this.db.updateUser(user, data) + } + + async getEnvVariables(projectId: string): Promise<{ [key: string]: string }> { + const environmentVariables = + await this.db.getEnvironmentVariablesByProjectId(projectId, { + environment: Environment.Production + }) + + const environmentVariablesObj = environmentVariables.reduce( + (acc, env) => { + acc[env.key] = env.value + return acc + }, + {} as { [key: string]: string } + ) + + return environmentVariablesObj + } + + async getAuctionData(auctionId: string): Promise { + const auctions = await this.laconicRegistry.getAuctionData(auctionId) + return auctions[0] + } + + async releaseDeployerFundsByProjectId(projectId: string): Promise { + const project = await this.db.getProjectById(projectId) + + if (!project || !project.auctionId) { + log( + `Project ${projectId} ${!project ? 'not found' : 'does not have an auction'}` + ) + + return false + } + + const auction = await this.laconicRegistry.releaseDeployerFunds( + project.auctionId + ) + + if (auction.auction.fundsReleased) { + log(`Funds released for auction ${project.auctionId}`) + await this.db.updateProjectById(projectId, { fundsReleased: true }) + + return true + } + + log(`Error releasing funds for auction ${project.auctionId}`) + + return false + } + + async returnUserFundsByProjectId( + projectId: string, + winningDeployersPresent: boolean + ) { + const project = await this.db.getProjectById(projectId) + + if (!project || !project.auctionId) { + log( + `Project ${projectId} ${!project ? 'not found' : 'does not have an auction'}` + ) + + return false + } + + const auction = await this.getAuctionData(project.auctionId) + const totalAuctionPrice = + Number(auction.maxPrice.quantity) * auction.numProviders + + let amountToBeReturned: number + if (winningDeployersPresent) { + amountToBeReturned = + totalAuctionPrice - + auction.winnerAddresses.length * Number(auction.winnerPrice.quantity) + } else { + amountToBeReturned = totalAuctionPrice + } + + if (amountToBeReturned !== 0) { + await this.laconicRegistry.sendTokensToAccount( + project.paymentAddress, + amountToBeReturned.toString() + ) + } + } + + async getDeployers(): Promise { + const dbDeployers = await this.db.getDeployers() + + if (dbDeployers.length > 0) { + // Call asynchronously to fetch the records from the registry and update the DB + this.updateDeployersFromRegistry() + return dbDeployers + } + + // Fetch from the registry and populate empty DB + return await this.updateDeployersFromRegistry() + } + + async updateDeployersFromRegistry(): Promise { + const deployerRecords = + await this.laconicRegistry.getDeployerRecordsByFilter({}) + await this.saveDeployersByDeployerRecords(deployerRecords) + + return await this.db.getDeployers() + } + + async saveDeployersByDeployerRecords( + deployerRecords: DeployerRecord[] + ): Promise { + const deployers: Deployer[] = [] + + for (const record of deployerRecords) { + if (record.names && record.names.length > 0) { + const deployerId = record.id + const deployerLrn = record.names[0] + const deployerApiUrl = record.attributes.apiUrl + const minimumPayment = record.attributes.minimumPayment + const paymentAddress = record.attributes.paymentAddress + const publicKey = record.attributes.publicKey + const baseDomain = deployerApiUrl.substring( + deployerApiUrl.indexOf('.') + 1 + ) + + const deployerData = { + deployerLrn, + deployerId, + deployerApiUrl, + baseDomain, + minimumPayment, + paymentAddress, + publicKey + } + + // TODO: Update deployers table in a separate job + const deployer = await this.db.addDeployer(deployerData) + deployers.push(deployer) + } + } + + return deployers + } + + async getAddress(): Promise { + const account = await this.laconicRegistry.getAccount() + + return account.address + } + + async verifyTx( + txHash: string, + amountSent: string, + senderAddress: string + ): Promise { + const txResponse = await this.laconicRegistry.getTxResponse(txHash) + if (!txResponse) { + log('Transaction response not found') + return false + } + + const transfer = txResponse.events.find( + (e: any) => + e.type === 'transfer' && + e.attributes.some((a: any) => a.key === 'msg_index') + ) + if (!transfer) { + log('No transfer event found') + return false + } + + const sender = transfer.attributes.find( + (a: any) => a.key === 'sender' + )?.value + const recipient = transfer.attributes.find( + (a: any) => a.key === 'recipient' + )?.value + const amount = transfer.attributes.find( + (a: any) => a.key === 'amount' + )?.value + + const recipientAddress = await this.getAddress() + + return ( + amount === amountSent && + sender === senderAddress && + recipient === recipientAddress + ) + } +} diff --git a/apps/backend/src/turnkey-backend.ts b/apps/backend/src/turnkey-backend.ts new file mode 100644 index 0000000..2336bdd --- /dev/null +++ b/apps/backend/src/turnkey-backend.ts @@ -0,0 +1,130 @@ +import { Turnkey, type TurnkeyApiTypes } from '@turnkey/sdk-server' + +// Default path for the first Ethereum address in a new HD wallet. +// See https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki, paths are in the form: +// m / purpose' / coin_type' / account' / change / address_index +// - Purpose is a constant set to 44' following the BIP43 recommendation. +// - Coin type is set to 60 (ETH) -- see https://github.com/satoshilabs/slips/blob/master/slip-0044.md +// - Account, Change, and Address Index are set to 0 +import { DEFAULT_ETHEREUM_ACCOUNTS } from '@turnkey/sdk-server' +import type { Service } from './service' +import { getConfig } from './utils' + +type TAttestation = TurnkeyApiTypes['v1Attestation'] + +type CreateUserParams = { + userName: string + userEmail: string + challenge: string + attestation: TAttestation +} + +export async function createUser( + service: Service, + { userName, userEmail, challenge, attestation }: CreateUserParams +) { + try { + if (await service.getUserByEmail(userEmail)) { + throw new Error(`User already exists: ${userEmail}`) + } + + const config = await getConfig() + const turnkey = new Turnkey(config.turnkey) + + const apiClient = turnkey.api() + + const walletName = 'Default ETH Wallet' + + const createSubOrgResponse = await apiClient.createSubOrganization({ + subOrganizationName: `Default SubOrg for ${userEmail}`, + rootQuorumThreshold: 1, + rootUsers: [ + { + userName, + userEmail, + apiKeys: [], + authenticators: [ + { + authenticatorName: 'Passkey', + challenge, + attestation + } + ] + } + ], + wallet: { + walletName: walletName, + accounts: DEFAULT_ETHEREUM_ACCOUNTS + } + }) + + const subOrgId = refineNonNull(createSubOrgResponse.subOrganizationId) + const wallet = refineNonNull(createSubOrgResponse.wallet) + + const result = { + id: wallet.walletId, + address: wallet.addresses[0], + subOrgId: subOrgId + } + console.log('Turnkey success', result) + + const user = await service.createUser({ + name: userName, + email: userEmail, + subOrgId, + ethAddress: wallet.addresses[0], + turnkeyWalletId: wallet.walletId + }) + console.log('New user', user) + + return user + } catch (e) { + console.error('Failed to create user:', e) + throw e + } +} + +export async function authenticateUser( + service: Service, + signedWhoamiRequest: { + url: string + body: any + stamp: { + stampHeaderName: string + stampHeaderValue: string + } + } +) { + try { + const tkRes = await fetch(signedWhoamiRequest.url, { + method: 'POST', + body: signedWhoamiRequest.body, + headers: { + [signedWhoamiRequest.stamp.stampHeaderName]: + signedWhoamiRequest.stamp.stampHeaderValue + } + }) + console.log('AUTH RESULT', tkRes.status) + if (tkRes.status !== 200) { + console.log(await tkRes.text()) + return null + } + const orgId = (await tkRes.json()).organizationId + const user = await service.getUserBySubOrgId(orgId) + return user + } catch (e) { + console.error('Failed to authenticate:', e) + throw e + } +} + +function refineNonNull( + input: T | null | undefined, + errorMessage?: string +): T { + if (input == null) { + throw new Error(errorMessage ?? `Unexpected ${JSON.stringify(input)}`) + } + + return input +} diff --git a/apps/backend/src/types.ts b/apps/backend/src/types.ts new file mode 100644 index 0000000..82f6111 --- /dev/null +++ b/apps/backend/src/types.ts @@ -0,0 +1,124 @@ +export interface PackageJSON { + name: string + version: string + author?: string + description?: string + homepage?: string + license?: string + repository?: string +} + +export interface GitRepositoryDetails { + id: number + name: string + full_name: string + visibility?: string + updated_at?: string | null + default_branch?: string +} + +export interface GitPushEventPayload { + repository: GitRepositoryDetails + ref: string + head_commit: { + id: string + message: string + } + deleted: boolean +} + +export interface AppDeploymentRecordAttributes { + application: string + auction: string + deployer: string + dns: string + meta: string + name: string + request: string + type: string + url: string + version: string +} + +export interface DNSRecordAttributes { + name: string + value: string + request: string + resourceType: string + version: string +} + +export interface RegistryDNSRecordAttributes { + name: string + value: string + request: string + resource_type: string + version: string +} + +export interface AppDeploymentRemovalRecordAttributes { + deployment: string + request: string + type: 'ApplicationDeploymentRemovalRecord' + version: string +} + +export interface RegistryRecord { + id: string + names: string[] | null + owners: string[] + bondId: string + createTime: string + expiryTime: string +} + +export interface AppDeploymentRecord extends RegistryRecord { + attributes: AppDeploymentRecordAttributes +} + +export interface AppDeploymentRemovalRecord extends RegistryRecord { + attributes: AppDeploymentRemovalRecordAttributes +} + +export interface DNSRecord extends RegistryRecord { + attributes: RegistryDNSRecordAttributes +} + +export interface AddProjectFromTemplateInput { + templateOwner: string + templateRepo: string + owner: string + name: string + isPrivate: boolean + paymentAddress: string + txHash: string +} + +export interface AuctionParams { + maxPrice: string + numProviders: number +} + +export interface EnvironmentVariables { + environments: string[] + key: string + value: string +} + +export interface DeployerRecord { + id: string + names: string[] + owners: string[] + bondId: string + createTime: string + expiryTime: string + attributes: { + apiUrl: string + minimumPayment: string | null + name: string + paymentAddress: string + publicKey: string + type: string + version: string + } +} diff --git a/apps/backend/src/utils.ts b/apps/backend/src/utils.ts new file mode 100644 index 0000000..072ba44 --- /dev/null +++ b/apps/backend/src/utils.ts @@ -0,0 +1,160 @@ +import debug from 'debug' +import fs from 'fs-extra' +import assert from 'node:assert' +import path from 'node:path' +import type { Octokit } from 'octokit' +import toml from 'toml' +import type { + DataSource, + DeepPartial, + EntityTarget, + ObjectLiteral +} from 'typeorm' + +import type { Config } from './config' + +interface PackageJSON { + name: string + description?: string + homepage?: string + license?: string + author?: string | { [key: string]: unknown } + version?: string + [key: string]: unknown +} + +const log = debug('snowball:utils') + +export async function getConfig() { + return await _getConfig( + path.join(__dirname, '../environments/local.toml') + ) +} + +const _getConfig = async ( + configFile: string +): Promise => { + const fileExists = await fs.pathExists(configFile) + if (!fileExists) { + throw new Error(`Config file not found: ${configFile}`) + } + + const config = toml.parse(await fs.readFile(configFile, 'utf8')) + log('config', JSON.stringify(config, null, 2)) + + return config +} + +export const checkFileExists = async (filePath: string): Promise => { + try { + await fs.access(filePath, fs.constants.F_OK) + return true + } catch (err) { + log(err) + return false + } +} + +export const getEntities = async (filePath: string): Promise => { + const entitiesData = await fs.readFile(filePath, 'utf-8') + const entities = JSON.parse(entitiesData) + return entities +} + +export const loadAndSaveData = async ( + entityType: EntityTarget, + dataSource: DataSource, + entities: any, + relations?: any | undefined +): Promise => { + const entityRepository = dataSource.getRepository(entityType) + + const savedEntity: Entity[] = [] + + for (const entityData of entities) { + let entity = entityRepository.create(entityData as DeepPartial) + + if (relations) { + for (const field in relations) { + const valueIndex = `${field}Index` + + entity = { + ...entity, + [field]: relations[field][entityData[valueIndex]] + } + } + } + const dbEntity = await entityRepository.save(entity) + savedEntity.push(dbEntity) + } + + return savedEntity +} + +export const sleep = async (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)) + +export const getRepoDetails = async ( + octokit: Octokit, + repository: string, + commitHash: string | undefined +): Promise<{ + repo: string + packageJSON: PackageJSON + repoUrl: string +}> => { + const [owner, repo] = repository.split('/') + const { data: packageJSONData } = await octokit.rest.repos.getContent({ + owner, + repo, + path: 'package.json', + ref: commitHash + }) + + if (!packageJSONData) { + throw new Error('Package.json file not found') + } + + assert(!Array.isArray(packageJSONData) && packageJSONData.type === 'file') + const packageJSON: PackageJSON = JSON.parse(atob(packageJSONData.content)) + + assert(packageJSON.name, "name field doesn't exist in package.json") + + const repoUrl = ( + await octokit.rest.repos.get({ + owner, + repo + }) + ).data.html_url + + return { + repo, + packageJSON, + repoUrl + } +} + +// Wrapper method for registry txs to retry once if 'account sequence mismatch' occurs +export const registryTransactionWithRetry = async ( + txMethod: () => Promise +): Promise => { + try { + return await txMethod() + } catch (error: any) { + if (!error.message.includes('account sequence mismatch')) { + throw error + } + + console.error( + 'Transaction failed due to account sequence mismatch. Retrying...' + ) + + try { + return await txMethod() + } catch (retryError: any) { + throw new Error( + `Transaction failed again after retry: ${retryError.message}` + ) + } + } +} diff --git a/apps/backend/test/delete-db.ts b/apps/backend/test/delete-db.ts new file mode 100644 index 0000000..1872b30 --- /dev/null +++ b/apps/backend/test/delete-db.ts @@ -0,0 +1,19 @@ +import * as fs from 'node:fs/promises' +import debug from 'debug' + +import { getConfig } from '../src/utils' + +const log = debug('snowball:delete-database') + +const deleteFile = async (filePath: string) => { + await fs.unlink(filePath) + log(`File ${filePath} has been deleted.`) +} + +const main = async () => { + const config = await getConfig() + + deleteFile(config.database.dbPath) +} + +main().catch((err) => log(err)) diff --git a/apps/backend/test/fixtures/deployments.json b/apps/backend/test/fixtures/deployments.json new file mode 100644 index 0000000..b73956f --- /dev/null +++ b/apps/backend/test/fixtures/deployments.json @@ -0,0 +1,189 @@ +[ + { + "projectIndex": 0, + "domainIndex": 0, + "createdByIndex": 0, + "id": "ffhae3zq", + "status": "Ready", + "environment": "Production", + "isCurrent": true, + "applicationRecordId": "qbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "xqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "main", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "testProject-ffhae3zq.snowball.xyz" + }, + { + "projectIndex": 0, + "domainIndex": 1, + "createdByIndex": 0, + "id": "vehagei8", + "status": "Ready", + "environment": "Preview", + "isCurrent": false, + "applicationRecordId": "wbafyreihvzya6ovp4yfpkqnddkui2iw7thbhwq74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "wqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "test", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "testProject-vehagei8.snowball.xyz" + }, + { + "projectIndex": 0, + "domainIndex": 2, + "createdByIndex": 0, + "id": "qmgekyte", + "status": "Ready", + "environment": "Development", + "isCurrent": false, + "applicationRecordId": "ebafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "kqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "test", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "testProject-qmgekyte.snowball.xyz" + }, + { + "projectIndex": 0, + "domainIndex": null, + "createdByIndex": 0, + "id": "f8wsyim6", + "status": "Ready", + "environment": "Production", + "isCurrent": false, + "applicationRecordId": "rbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhw74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "yqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "prod", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "testProject-f8wsyim6.snowball.xyz" + }, + { + "projectIndex": 1, + "domainIndex": 3, + "createdByIndex": 1, + "id": "eO8cckxk", + "status": "Ready", + "environment": "Production", + "isCurrent": true, + "applicationRecordId": "tbafyreihvzya6ovp4yfpqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "main", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "testProject-2-eO8cckxk.snowball.xyz" + }, + { + "projectIndex": 1, + "domainIndex": 4, + "createdByIndex": 1, + "id": "yaq0t5yw", + "status": "Ready", + "environment": "Preview", + "isCurrent": false, + "applicationRecordId": "ybafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "tqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "test", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "testProject-2-yaq0t5yw.snowball.xyz" + }, + { + "projectIndex": 1, + "domainIndex": 5, + "createdByIndex": 1, + "id": "hwwr6sbx", + "status": "Ready", + "environment": "Development", + "isCurrent": false, + "applicationRecordId": "ubafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "eqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "test", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "testProject-2-hwwr6sbx.snowball.xyz" + }, + { + "projectIndex": 2, + "domainIndex": 9, + "createdByIndex": 2, + "id": "ndxje48a", + "status": "Ready", + "environment": "Production", + "isCurrent": true, + "applicationRecordId": "ibayreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "dqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "main", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "iglootools-ndxje48a.snowball.xyz" + }, + { + "projectIndex": 2, + "domainIndex": 7, + "createdByIndex": 2, + "id": "gtgpgvei", + "status": "Ready", + "environment": "Preview", + "isCurrent": false, + "applicationRecordId": "obafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "aqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "test", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "iglootools-gtgpgvei.snowball.xyz" + }, + { + "projectIndex": 2, + "domainIndex": 8, + "createdByIndex": 2, + "id": "b4bpthjr", + "status": "Ready", + "environment": "Development", + "isCurrent": false, + "applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "uqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "test", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "iglootools-b4bpthjr.snowball.xyz" + }, + { + "projectIndex": 3, + "domainIndex": 6, + "createdByIndex": 2, + "id": "b4bpthjr", + "status": "Ready", + "environment": "Production", + "isCurrent": true, + "applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "test", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "iglootools-b4bpthjr.snowball.xyz" + } +] diff --git a/apps/backend/test/fixtures/environment-variables.json b/apps/backend/test/fixtures/environment-variables.json new file mode 100644 index 0000000..2af52b3 --- /dev/null +++ b/apps/backend/test/fixtures/environment-variables.json @@ -0,0 +1,92 @@ +[ + { + "projectIndex": 0, + "key": "ABC", + "value": "ABC", + "environment": "Production" + }, + { + "projectIndex": 0, + "key": "ABC", + "value": "ABC", + "environment": "Preview" + }, + { + "projectIndex": 0, + "key": "XYZ", + "value": "abc3", + "environment": "Preview" + }, + { + "projectIndex": 1, + "key": "ABC", + "value": "ABC", + "environment": "Production" + }, + { + "projectIndex": 1, + "key": "ABC", + "value": "ABC", + "environment": "Preview" + }, + { + "projectIndex": 1, + "key": "XYZ", + "value": "abc3", + "environment": "Preview" + }, + { + "projectIndex": 2, + "key": "ABC", + "value": "ABC", + "environment": "Production" + }, + { + "projectIndex": 2, + "key": "ABC", + "value": "ABC", + "environment": "Preview" + }, + { + "projectIndex": 2, + "key": "XYZ", + "value": "abc3", + "environment": "Preview" + }, + { + "projectIndex": 3, + "key": "ABC", + "value": "ABC", + "environment": "Production" + }, + { + "projectIndex": 3, + "key": "ABC", + "value": "ABC", + "environment": "Preview" + }, + { + "projectIndex": 3, + "key": "XYZ", + "value": "abc3", + "environment": "Preview" + }, + { + "projectIndex": 4, + "key": "ABC", + "value": "ABC", + "environment": "Production" + }, + { + "projectIndex": 4, + "key": "ABC", + "value": "ABC", + "environment": "Preview" + }, + { + "projectIndex": 4, + "key": "XYZ", + "value": "abc3", + "environment": "Preview" + } +] diff --git a/apps/backend/test/fixtures/organizations.json b/apps/backend/test/fixtures/organizations.json new file mode 100644 index 0000000..0d688fb --- /dev/null +++ b/apps/backend/test/fixtures/organizations.json @@ -0,0 +1,7 @@ +[ + { + "id": "2379cf1f-a232-4ad2-ae14-4d881131cc26", + "name": "Deploy Tools", + "slug": "deploy-tools" + } +] diff --git a/apps/backend/test/fixtures/primary-domains.json b/apps/backend/test/fixtures/primary-domains.json new file mode 100644 index 0000000..23e2666 --- /dev/null +++ b/apps/backend/test/fixtures/primary-domains.json @@ -0,0 +1,44 @@ +[ + { + "projectIndex": 0, + "name": "example.snowballtools.xyz", + "status": "Live", + "branch": "main" + }, + { + "projectIndex": 0, + "name": "example.org", + "status": "Pending", + "branch": "test" + }, + { + "projectIndex": 1, + "name": "example.snowballtools.xyz", + "status": "Live", + "branch": "main" + }, + { + "projectIndex": 1, + "name": "example.org", + "status": "Pending", + "branch": "test" + }, + { + "projectIndex": 2, + "name": "example.snowballtools.xyz", + "status": "Live", + "branch": "main" + }, + { + "projectIndex": 2, + "name": "example.org", + "status": "Pending", + "branch": "test" + }, + { + "projectIndex": 3, + "name": "iglootools-2.com", + "status": "Pending", + "branch": "test" + } +] diff --git a/apps/backend/test/fixtures/project-members.json b/apps/backend/test/fixtures/project-members.json new file mode 100644 index 0000000..84029c2 --- /dev/null +++ b/apps/backend/test/fixtures/project-members.json @@ -0,0 +1,56 @@ +[ + { + "memberIndex": 1, + "projectIndex": 0, + "permissions": ["View"], + "isPending": false + }, + { + "memberIndex": 2, + "projectIndex": 0, + "permissions": ["View", "Edit"], + "isPending": false + }, + { + "memberIndex": 2, + "projectIndex": 1, + "permissions": ["View"], + "isPending": false + }, + { + "memberIndex": 0, + "projectIndex": 2, + "permissions": ["View"], + "isPending": false + }, + { + "memberIndex": 1, + "projectIndex": 2, + "permissions": ["View", "Edit"], + "isPending": false + }, + { + "memberIndex": 0, + "projectIndex": 3, + "permissions": ["View"], + "isPending": false + }, + { + "memberIndex": 2, + "projectIndex": 3, + "permissions": ["View", "Edit"], + "isPending": false + }, + { + "memberIndex": 1, + "projectIndex": 4, + "permissions": ["View"], + "isPending": false + }, + { + "memberIndex": 2, + "projectIndex": 4, + "permissions": ["View", "Edit"], + "isPending": false + } +] diff --git a/apps/backend/test/fixtures/projects.json b/apps/backend/test/fixtures/projects.json new file mode 100644 index 0000000..f456464 --- /dev/null +++ b/apps/backend/test/fixtures/projects.json @@ -0,0 +1,67 @@ +[ + { + "ownerIndex": 0, + "organizationIndex": 0, + "name": "testProject", + "repository": "snowball-tools/snowball-ts-framework-template", + "prodBranch": "main", + "description": "test", + "template": "webapp", + "framework": "test", + "webhooks": [], + "icon": "", + "subDomain": "testProject.snowball.xyz" + }, + { + "ownerIndex": 1, + "organizationIndex": 0, + "name": "testProject-2", + "repository": "snowball-tools/snowball-ts-framework-template", + "prodBranch": "main", + "description": "test-2", + "template": "webapp", + "framework": "test-2", + "webhooks": [], + "icon": "", + "subDomain": "testProject-2.snowball.xyz" + }, + { + "ownerIndex": 2, + "organizationIndex": 0, + "name": "iglootools", + "repository": "snowball-tools/snowball-ts-framework-template", + "prodBranch": "main", + "description": "test-3", + "template": "webapp", + "framework": "test-3", + "webhooks": [], + "icon": "", + "subDomain": "iglootools.snowball.xyz" + }, + { + "ownerIndex": 1, + "organizationIndex": 0, + "name": "iglootools-2", + "repository": "snowball-tools/snowball-ts-framework-template", + "prodBranch": "main", + "description": "test-4", + "template": "webapp", + "framework": "test-4", + "webhooks": [], + "icon": "", + "subDomain": "iglootools-2.snowball.xyz" + }, + { + "ownerIndex": 0, + "organizationIndex": 1, + "name": "snowball-2", + "repository": "snowball-tools/snowball-ts-framework-template", + "prodBranch": "main", + "description": "test-5", + "template": "webapp", + "framework": "test-5", + "webhooks": [], + "icon": "", + "subDomain": "snowball-2.snowball.xyz" + } +] diff --git a/apps/backend/test/fixtures/redirected-domains.json b/apps/backend/test/fixtures/redirected-domains.json new file mode 100644 index 0000000..64fa973 --- /dev/null +++ b/apps/backend/test/fixtures/redirected-domains.json @@ -0,0 +1,23 @@ +[ + { + "projectIndex": 0, + "name": "www.example.org", + "status": "Pending", + "redirectToIndex": 1, + "branch": "test" + }, + { + "projectIndex": 1, + "name": "www.example.org", + "status": "Pending", + "redirectToIndex": 3, + "branch": "test" + }, + { + "projectIndex": 2, + "name": "www.example.org", + "status": "Pending", + "redirectToIndex": 5, + "branch": "test" + } +] diff --git a/apps/backend/test/fixtures/user-organizations.json b/apps/backend/test/fixtures/user-organizations.json new file mode 100644 index 0000000..adff4ae --- /dev/null +++ b/apps/backend/test/fixtures/user-organizations.json @@ -0,0 +1,22 @@ +[ + { + "role": "Owner", + "memberIndex": 0, + "organizationIndex": 0 + }, + { + "role": "Maintainer", + "memberIndex": 1, + "organizationIndex": 0 + }, + { + "role": "Owner", + "memberIndex": 2, + "organizationIndex": 0 + }, + { + "role": "Owner", + "memberIndex": 0, + "organizationIndex": 1 + } +] diff --git a/apps/backend/test/fixtures/users.json b/apps/backend/test/fixtures/users.json new file mode 100644 index 0000000..bc1a44d --- /dev/null +++ b/apps/backend/test/fixtures/users.json @@ -0,0 +1,23 @@ +[ + { + "id": "59f4355d-9549-4aac-9b54-eeefceeabef0", + "name": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "email": "snowball@snowballtools.xyz", + "isVerified": true, + "ethAddress": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + }, + { + "id": "e505b212-8da6-48b2-9614-098225dab34b", + "name": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8", + "email": "alice@snowballtools.xyz", + "isVerified": true, + "ethAddress": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8" + }, + { + "id": "cd892fad-9138-4aa2-a62c-414a32776ea7", + "name": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a", + "email": "bob@snowballtools.xyz", + "isVerified": true, + "ethAddress": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a" + } +] diff --git a/apps/backend/test/initialize-db.ts b/apps/backend/test/initialize-db.ts new file mode 100644 index 0000000..3aff317 --- /dev/null +++ b/apps/backend/test/initialize-db.ts @@ -0,0 +1,176 @@ +import path from 'node:path' +import debug from 'debug' +import { DataSource } from 'typeorm' + +import { Deployment } from '../src/entity/Deployment' +import { Domain } from '../src/entity/Domain' +import { EnvironmentVariable } from '../src/entity/EnvironmentVariable' +import { Organization } from '../src/entity/Organization' +import { Project } from '../src/entity/Project' +import { ProjectMember } from '../src/entity/ProjectMember' +import { User } from '../src/entity/User' +import { UserOrganization } from '../src/entity/UserOrganization' +import { + checkFileExists, + getConfig, + getEntities, + loadAndSaveData +} from '../src/utils' + +const log = debug('snowball:initialize-database') + +const USER_DATA_PATH = './fixtures/users.json' +const PROJECT_DATA_PATH = './fixtures/projects.json' +const ORGANIZATION_DATA_PATH = './fixtures/organizations.json' +const USER_ORGANIZATION_DATA_PATH = './fixtures/user-organizations.json' +const PROJECT_MEMBER_DATA_PATH = './fixtures/project-members.json' +const PRIMARY_DOMAIN_DATA_PATH = './fixtures/primary-domains.json' +const DEPLOYMENT_DATA_PATH = './fixtures/deployments.json' +const ENVIRONMENT_VARIABLE_DATA_PATH = './fixtures/environment-variables.json' +const REDIRECTED_DOMAIN_DATA_PATH = './fixtures/redirected-domains.json' + +const generateTestData = async (dataSource: DataSource) => { + const userEntities = await getEntities( + path.resolve(__dirname, USER_DATA_PATH) + ) + const savedUsers = await loadAndSaveData(User, dataSource, userEntities) + + const orgEntities = await getEntities( + path.resolve(__dirname, ORGANIZATION_DATA_PATH) + ) + const savedOrgs = await loadAndSaveData(Organization, dataSource, orgEntities) + + const projectRelations = { + owner: savedUsers, + organization: savedOrgs + } + + const projectEntities = await getEntities( + path.resolve(__dirname, PROJECT_DATA_PATH) + ) + const savedProjects = await loadAndSaveData( + Project, + dataSource, + projectEntities, + projectRelations + ) + + const domainRepository = dataSource.getRepository(Domain) + + const domainPrimaryRelations = { + project: savedProjects + } + + const primaryDomainsEntities = await getEntities( + path.resolve(__dirname, PRIMARY_DOMAIN_DATA_PATH) + ) + const savedPrimaryDomains = await loadAndSaveData( + Domain, + dataSource, + primaryDomainsEntities, + domainPrimaryRelations + ) + + const domainRedirectedRelations = { + project: savedProjects, + redirectTo: savedPrimaryDomains + } + + const redirectDomainsEntities = await getEntities( + path.resolve(__dirname, REDIRECTED_DOMAIN_DATA_PATH) + ) + await loadAndSaveData( + Domain, + dataSource, + redirectDomainsEntities, + domainRedirectedRelations + ) + + const savedDomains = await domainRepository.find() + + const userOrganizationRelations = { + member: savedUsers, + organization: savedOrgs + } + + const userOrganizationsEntities = await getEntities( + path.resolve(__dirname, USER_ORGANIZATION_DATA_PATH) + ) + await loadAndSaveData( + UserOrganization, + dataSource, + userOrganizationsEntities, + userOrganizationRelations + ) + + const projectMemberRelations = { + member: savedUsers, + project: savedProjects + } + + const projectMembersEntities = await getEntities( + path.resolve(__dirname, PROJECT_MEMBER_DATA_PATH) + ) + await loadAndSaveData( + ProjectMember, + dataSource, + projectMembersEntities, + projectMemberRelations + ) + + const deploymentRelations = { + project: savedProjects, + domain: savedDomains, + createdBy: savedUsers + } + + const deploymentsEntities = await getEntities( + path.resolve(__dirname, DEPLOYMENT_DATA_PATH) + ) + await loadAndSaveData( + Deployment, + dataSource, + deploymentsEntities, + deploymentRelations + ) + + const environmentVariableRelations = { + project: savedProjects + } + + const environmentVariablesEntities = await getEntities( + path.resolve(__dirname, ENVIRONMENT_VARIABLE_DATA_PATH) + ) + await loadAndSaveData( + EnvironmentVariable, + dataSource, + environmentVariablesEntities, + environmentVariableRelations + ) +} + +const main = async () => { + const config = await getConfig() + const isDbPresent = await checkFileExists(config.database.dbPath) + + if (!isDbPresent) { + const dataSource = new DataSource({ + type: 'better-sqlite3', + database: config.database.dbPath, + synchronize: true, + logging: true, + entities: [path.join(__dirname, '../src/entity/*')] + }) + + await dataSource.initialize() + + await generateTestData(dataSource) + log('Data loaded successfully') + } else { + log('WARNING: Database already exists') + } +} + +main().catch((err) => { + log(err) +}) diff --git a/apps/backend/test/initialize-registry.ts b/apps/backend/test/initialize-registry.ts new file mode 100644 index 0000000..27c303d --- /dev/null +++ b/apps/backend/test/initialize-registry.ts @@ -0,0 +1,49 @@ +import debug from 'debug' + +import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk' + +import { getConfig } from '../src/utils' + +const log = debug('snowball:initialize-registry') + +const DENOM = 'alnt' +const BOND_AMOUNT = '1000000000' + +async function main() { + const { registryConfig } = await getConfig() + + // TODO: Get authority names from args + const authorityNames = ['snowballtools', registryConfig.authority] + + const registry = new Registry( + registryConfig.gqlEndpoint, + registryConfig.restEndpoint, + { chainId: registryConfig.chainId } + ) + + const bondId = await registry.getNextBondId(registryConfig.privateKey) + log('bondId:', bondId) + + const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees) + + await registry.createBond( + { denom: DENOM, amount: BOND_AMOUNT }, + registryConfig.privateKey, + fee + ) + + for await (const name of authorityNames) { + await registry.reserveAuthority({ name }, registryConfig.privateKey, fee) + log('Reserved authority name:', name) + await registry.setAuthorityBond( + { name, bondId }, + registryConfig.privateKey, + fee + ) + log(`Bond ${bondId} set for authority ${name}`) + } +} + +main().catch((err) => { + log(err) +}) diff --git a/apps/backend/test/publish-deploy-records.ts b/apps/backend/test/publish-deploy-records.ts new file mode 100644 index 0000000..61d424e --- /dev/null +++ b/apps/backend/test/publish-deploy-records.ts @@ -0,0 +1,100 @@ +import path from 'node:path' +import debug from 'debug' +import { DataSource } from 'typeorm' + +import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk' + +import { + Deployment, + DeploymentStatus, + Environment +} from '../src/entity/Deployment' +import { getConfig } from '../src/utils' + +const log = debug('snowball:publish-deploy-records') + +async function main() { + const { registryConfig, database } = await getConfig() + + const registry = new Registry( + registryConfig.gqlEndpoint, + registryConfig.restEndpoint, + { chainId: registryConfig.chainId } + ) + + const dataSource = new DataSource({ + type: 'better-sqlite3', + database: database.dbPath, + synchronize: true, + entities: [path.join(__dirname, '../src/entity/*')] + }) + + await dataSource.initialize() + + const deploymentRepository = dataSource.getRepository(Deployment) + const deployments = await deploymentRepository.find({ + relations: { + project: true + }, + where: { + status: DeploymentStatus.Building + } + }) + + for await (const deployment of deployments) { + const url = `https://${(deployment.project.name).toLowerCase()}-${deployment.id}.${deployment.deployer.baseDomain}` + + const applicationDeploymentRecord = { + type: 'ApplicationDeploymentRecord', + version: '0.0.1', + name: deployment.applicationRecordData.name, + application: deployment.applicationRecordId, + + // TODO: Create DNS record + dns: 'bafyreihlymqggsgqiqawvehkpr2imt4l3u6q7um7xzjrux5rhsvwnuyewm', + + // Using dummy values + meta: JSON.stringify({ + config: 'da39a3ee5e6b4b0d3255bfef95601890afd80709', + so: '66fcfa49a1664d4cb4ce4f72c1c0e151' + }), + + request: deployment.applicationDeploymentRequestId, + url + } + + const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees) + + const result = await registry.setRecord( + { + privateKey: registryConfig.privateKey, + record: applicationDeploymentRecord, + bondId: registryConfig.bondId + }, + '', + fee + ) + + // Remove deployment for project subdomain if deployment is for production environment + if (deployment.environment === Environment.Production) { + applicationDeploymentRecord.url = `https://${deployment.project.name}.${deployment.deployer.baseDomain}` + + await registry.setRecord( + { + privateKey: registryConfig.privateKey, + record: applicationDeploymentRecord, + bondId: registryConfig.bondId + }, + '', + fee + ) + } + + log('Application deployment record data:', applicationDeploymentRecord) + log(`Application deployment record published: ${result.id}`) + } +} + +main().catch((err) => { + log(err) +}) diff --git a/apps/backend/test/publish-deployment-removal-records.ts b/apps/backend/test/publish-deployment-removal-records.ts new file mode 100644 index 0000000..e902fc7 --- /dev/null +++ b/apps/backend/test/publish-deployment-removal-records.ts @@ -0,0 +1,70 @@ +import path from 'node:path' +import debug from 'debug' +import { DataSource } from 'typeorm' + +import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk' + +import { Deployment, DeploymentStatus } from '../src/entity/Deployment' +import { getConfig } from '../src/utils' + +const log = debug('snowball:publish-deployment-removal-records') + +async function main() { + const { registryConfig, database } = await getConfig() + + const registry = new Registry( + registryConfig.gqlEndpoint, + registryConfig.restEndpoint, + { chainId: registryConfig.chainId } + ) + + const dataSource = new DataSource({ + type: 'better-sqlite3', + database: database.dbPath, + synchronize: true, + entities: [path.join(__dirname, '../src/entity/*')] + }) + + await dataSource.initialize() + + const deploymentRepository = dataSource.getRepository(Deployment) + const deployments = await deploymentRepository.find({ + relations: { + project: true + }, + where: { + status: DeploymentStatus.Deleting + } + }) + + for await (const deployment of deployments) { + const applicationDeploymentRemovalRecord = { + type: 'ApplicationDeploymentRemovalRecord', + version: '1.0.0', + deployment: deployment.applicationDeploymentRecordId, + request: deployment.applicationDeploymentRemovalRequestId + } + + const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees) + + const result = await registry.setRecord( + { + privateKey: registryConfig.privateKey, + record: applicationDeploymentRemovalRecord, + bondId: registryConfig.bondId + }, + '', + fee + ) + + log( + 'Application deployment removal record data:', + applicationDeploymentRemovalRecord + ) + log(`Application deployment removal record published: ${result.id}`) + } +} + +main().catch((err) => { + log(err) +}) diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 0000000..d4cfe92 --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "baseUrl": ".", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "src/**/*.test.ts"] +} diff --git a/apps/deploy-fe/.env.example b/apps/deploy-fe/.env.example new file mode 100644 index 0000000..861ca8e --- /dev/null +++ b/apps/deploy-fe/.env.example @@ -0,0 +1,6 @@ +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= +NEXT_PUBLIC_WALLET_IFRAME_URL= # wherever your wallet is running +NEXT_PUBLIC_LACONICD_CHAIN_ID= # the appropriate chain ID for your network +NEXT_PUBLIC_API_URL= +NEXT_PUBLIC_GITHUB_FALLBACK_TOKEN= \ No newline at end of file diff --git a/apps/deploy-fe/.gitignore b/apps/deploy-fe/.gitignore new file mode 100644 index 0000000..d021fb9 --- /dev/null +++ b/apps/deploy-fe/.gitignore @@ -0,0 +1,12 @@ +node_modules +.next/ +.turbo/ +.env +.env.local +.env.development.local +.env.test.local + +# clerk configuration (can include secrets) +/.clerk/ +.vercel +.clerk/ \ No newline at end of file diff --git a/apps/deploy-fe/.vscode/settings.json b/apps/deploy-fe/.vscode/settings.json new file mode 100644 index 0000000..28c0bf2 --- /dev/null +++ b/apps/deploy-fe/.vscode/settings.json @@ -0,0 +1,45 @@ +{ + // Project-specific formatter choice + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, + + // TypeScript configuration + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + + // Code actions + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.addMissingImports.ts": "explicit", + "source.organizeImports.biome": "explicit", + "source.removeUnused.ts": "explicit" + }, + + // Language-specific formatters for this project + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + + // TypeScript-specific performance settings for this project + "typescript.preferGoToSourceDefinition": true, + "typescript.suggest.paths": true, + "typescript.tsserver.disableAutomaticTypeAcquisition": false, + + // TypeScript server project-specific settings + "typescript.tsserver.maxTsServerMemory": 8192, + "typescript.tsserver.experimental.enableProjectDiagnostics": true, + "typescript.tsserver.enableTracing": false, + + // For large TypeScript projects + "search.exclude": { + "**/node_modules": true, + "**/dist": true, + "**/build": true + }, + + // For better JSDoc documentation + "javascript.suggest.completeJSDocs": true +} diff --git a/apps/deploy-fe/biome.jsonc b/apps/deploy-fe/biome.jsonc new file mode 100644 index 0000000..52a1d9f --- /dev/null +++ b/apps/deploy-fe/biome.jsonc @@ -0,0 +1,3 @@ +{ + "extends": ["../../biome.json"] +} diff --git a/apps/deploy-fe/components.json b/apps/deploy-fe/components.json new file mode 100644 index 0000000..bdeb87e --- /dev/null +++ b/apps/deploy-fe/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "../../services/ui/tailwind.config.ts", + "css": "../../services/ui/src/styles/globals.css", + "baseColor": "zinc", + "cssVariables": true + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "hooks": "@/hooks", + "lib": "@/lib", + "utils": "@workspace/ui/lib/utils", + "ui": "@workspace/ui/components" + } +} diff --git a/apps/deploy-fe/next.config.mjs b/apps/deploy-fe/next.config.mjs new file mode 100644 index 0000000..1ebcb2b --- /dev/null +++ b/apps/deploy-fe/next.config.mjs @@ -0,0 +1,16 @@ +import dotenv from 'dotenv' + +// Load environment variables from .env.development.local +dotenv.config({ path: '.env.development.local' }) + +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ['@workspace/ui'], + env: { + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, + CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY + } +} + +export default nextConfig diff --git a/apps/deploy-fe/package.json b/apps/deploy-fe/package.json new file mode 100644 index 0000000..1142665 --- /dev/null +++ b/apps/deploy-fe/package.json @@ -0,0 +1,90 @@ +{ + "name": "deploy-fe", + "version": "0.0.1", + "type": "module", + "private": true, + "scripts": { + "dev": "NODE_OPTIONS='--inspect' next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format .", + "format:fix": "biome format --write .", + "check-types": "tsc --noEmit", + "fix-types": "tsc --noEmit --pretty --incremental" + }, + "dependencies": { + "@biomejs/biome": "^1.9.4", + "@clerk/nextjs": "^6.12.4", + "@clerk/themes": "^2.2.20", + "@hookform/resolvers": "^4.1.2", + "@octokit/rest": "^21.1.1", + "@octokit/webhooks-types": "^7.6.1", + "@radix-ui/react-accordion": "^1.2.3", + "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-aspect-ratio": "^1.1.2", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-context-menu": "^2.2.6", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-hover-card": "^1.1.6", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-menubar": "^1.1.6", + "@radix-ui/react-navigation-menu": "^1.2.5", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.2.3", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", + "@radix-ui/react-visually-hidden": "^1.1.2", + "@workspace/ui": "workspace:*", + "axios": "^1.8.4", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "cmdk": "1.0.4", + "date-fns": "^4.1.0", + "downshift": "^9.0.9", + "embla-carousel-react": "^8.5.2", + "input-otp": "^1.4.2", + "lucide-react": "0.477.0", + "next": "^15.2.1", + "next-themes": "^0.4.4", + "octokit": "^3.1.2", + "react": "^19.0.0", + "react-day-picker": "8.10.1", + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", + "react-resizable-panels": "^2.1.7", + "recharts": "^2.15.1", + "siwe": "^3.0.0", + "sonner": "^2.0.1", + "tailwind-merge": "^3.0.2", + "usehooks-ts": "^3.1.1", + "vaul": "^1.1.2", + "zod": "^3.23.8", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "18.3.0", + "@types/react-dom": "18.3.1", + "@workspace/gql-client": "workspace:*", + "@workspace/typescript-config": "workspace:*", + "dotenv": "^16.4.7", + "postcss": "^8", + "tailwindcss": "^3.4.17", + "typescript": "^5" + } +} diff --git a/apps/deploy-fe/postcss.config.mjs b/apps/deploy-fe/postcss.config.mjs new file mode 100644 index 0000000..5f90e69 --- /dev/null +++ b/apps/deploy-fe/postcss.config.mjs @@ -0,0 +1 @@ +export { default } from '@workspace/ui/postcss.config' diff --git a/apps/deploy-fe/repo_structure.txt b/apps/deploy-fe/repo_structure.txt new file mode 100644 index 0000000..7e31950 --- /dev/null +++ b/apps/deploy-fe/repo_structure.txt @@ -0,0 +1,220 @@ +./.env.local +./.gitignore +./.turbo/turbo-build.log +./.vscode/settings.json +./biome.jsonc +./components.json +./next-env.d.ts +./next.config.mjs +./package.json +./postcss.config.mjs +./repo_structure.txt +./src/actions/github.ts +./src/app/(web3-authenticated)/(dashboard)/documentation/DocumentationPlaceholder.tsx +./src/app/(web3-authenticated)/(dashboard)/documentation/page.tsx +./src/app/(web3-authenticated)/(dashboard)/home/loading.tsx +./src/app/(web3-authenticated)/(dashboard)/home/page.tsx +./src/app/(web3-authenticated)/(dashboard)/layout.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(configure)/cf/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(deploy)/dp/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(success)/sc/[id]/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(template)/tm/(configure)/cf/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(template)/tm/(deploy)/dp/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(deployments)/dep/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(integrations)/int/GitPage.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(integrations)/int/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(collaborators)/col/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(domains)/dom/(add)/cf/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(domains)/dom/(add)/config/cf/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(environment-variables)/env/EnvVarsPage.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(environment-variables)/env/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(git)/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/ProjectSettingsPage.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/deployments/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/layout.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/loading.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/error.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/loading.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/page.tsx +./src/app/(web3-authenticated)/(dashboard)/purchase/BuyServices.tsx +./src/app/(web3-authenticated)/(dashboard)/purchase/page.tsx +./src/app/(web3-authenticated)/(dashboard)/store/page.tsx +./src/app/(web3-authenticated)/(dashboard)/support/SupportPlaceholder.tsx +./src/app/(web3-authenticated)/(dashboard)/support/page.tsx +./src/app/(web3-authenticated)/(dashboard)/wallet/page.tsx +./src/app/(web3-authenticated)/layout.tsx +./src/app/actions/github.ts +./src/app/api/auth/route.ts +./src/app/api/github/webhook/route.ts +./src/app/favicon.ico +./src/app/layout.tsx +./src/app/loading.tsx +./src/app/page.tsx +./src/app/sign-in/[[...sign-in]]/page.tsx +./src/components/assets/laconic-mark.tsx +./src/components/core/dropdown/Dropdown.tsx +./src/components/core/dropdown/README.md +./src/components/core/dropdown/index.ts +./src/components/core/dropdown/types.ts +./src/components/core/format-milli-second/FormatMilliSecond.tsx +./src/components/core/format-milli-second/README.md +./src/components/core/format-milli-second/index.ts +./src/components/core/format-milli-second/types.ts +./src/components/core/logo/Logo.tsx +./src/components/core/logo/README.md +./src/components/core/logo/index.ts +./src/components/core/logo/types.ts +./src/components/core/search-bar/README.md +./src/components/core/search-bar/SearchBar.tsx +./src/components/core/search-bar/index.ts +./src/components/core/search-bar/types.ts +./src/components/core/stepper/README.md +./src/components/core/stepper/Stepper.tsx +./src/components/core/stepper/index.ts +./src/components/core/stepper/types.ts +./src/components/core/stop-watch/README.md +./src/components/core/stop-watch/StopWatch.tsx +./src/components/core/stop-watch/index.ts +./src/components/core/stop-watch/types.ts +./src/components/core/vertical-stepper/README.md +./src/components/core/vertical-stepper/VerticalStepper.tsx +./src/components/core/vertical-stepper/index.ts +./src/components/core/vertical-stepper/types.ts +./src/components/foundation/coming-soon-overlay/ComingSoonOverlay.tsx +./src/components/foundation/coming-soon-overlay/index.ts +./src/components/foundation/github-session-button/GitHubSessionButton.tsx +./src/components/foundation/github-session-button/README.md +./src/components/foundation/github-session-button/index.ts +./src/components/foundation/github-session-button/types.ts +./src/components/foundation/index.ts +./src/components/foundation/laconic-icon/LaconicIcon.tsx +./src/components/foundation/laconic-icon/README.md +./src/components/foundation/laconic-icon/index.ts +./src/components/foundation/laconic-icon/types.ts +./src/components/foundation/loading/loading-overlay/LoadingOverlay.tsx +./src/components/foundation/loading/loading-overlay/README.md +./src/components/foundation/loading/loading-overlay/index.ts +./src/components/foundation/navigation-wrapper/NavigationWrapper.tsx +./src/components/foundation/navigation-wrapper/README.md +./src/components/foundation/navigation-wrapper/index.ts +./src/components/foundation/page-header/PageHeader.tsx +./src/components/foundation/page-header/README.md +./src/components/foundation/page-header/index.ts +./src/components/foundation/page-wrapper/PageWrapper.tsx +./src/components/foundation/page-wrapper/README.md +./src/components/foundation/page-wrapper/index.ts +./src/components/foundation/project-search-bar/ProjectSearchBar.tsx +./src/components/foundation/project-search-bar/README.md +./src/components/foundation/project-search-bar/index.ts +./src/components/foundation/project-search-bar/types.ts +./src/components/foundation/top-navigation/README.md +./src/components/foundation/top-navigation/dark-mode-toggle/DarkModeToggle.tsx +./src/components/foundation/top-navigation/dark-mode-toggle/README.md +./src/components/foundation/top-navigation/dark-mode-toggle/index.ts +./src/components/foundation/top-navigation/index.ts +./src/components/foundation/top-navigation/main-navigation/MainNavigation.tsx +./src/components/foundation/top-navigation/main-navigation/README.md +./src/components/foundation/top-navigation/main-navigation/index.ts +./src/components/foundation/top-navigation/navigation-item/NavigationItem.tsx +./src/components/foundation/top-navigation/navigation-item/README.md +./src/components/foundation/top-navigation/navigation-item/index.ts +./src/components/foundation/top-navigation/types.ts +./src/components/foundation/top-navigation/wallet-session-badge/README.md +./src/components/foundation/top-navigation/wallet-session-badge/WalletSessionBadge.tsx +./src/components/foundation/top-navigation/wallet-session-badge/index.ts +./src/components/foundation/types.ts +./src/components/foundation/wallet-session-id/README.md +./src/components/foundation/wallet-session-id/WalletSessionId.tsx +./src/components/foundation/wallet-session-id/index.ts +./src/components/foundation/wallet-session-id/types.ts +./src/components/iframe/auto-sign-in/AutoSignInIFrameModal.tsx +./src/components/iframe/auto-sign-in/README.md +./src/components/iframe/auto-sign-in/index.ts +./src/components/iframe/auto-sign-in/types.ts +./src/components/iframe/check-balance-iframe/CheckBalanceIframe.tsx +./src/components/iframe/check-balance-iframe/useCheckBalance.tsx +./src/components/layout/index.ts +./src/components/layout/navigation/github-session-button/GitHubSessionButton.tsx +./src/components/layout/navigation/github-session-button/README.md +./src/components/layout/navigation/github-session-button/index.ts +./src/components/layout/navigation/github-session-button/types.ts +./src/components/layout/navigation/laconic-icon/LaconicIcon.tsx +./src/components/layout/navigation/laconic-icon/README.md +./src/components/layout/navigation/laconic-icon/index.ts +./src/components/layout/navigation/laconic-icon/types.ts +./src/components/layout/navigation/navigation-actions/NavigationActions.tsx +./src/components/layout/navigation/navigation-actions/README.md +./src/components/layout/navigation/navigation-actions/index.ts +./src/components/layout/navigation/navigation-actions/types.ts +./src/components/layout/navigation/wallet-session-id/README.md +./src/components/layout/navigation/wallet-session-id/WalletSessionId.tsx +./src/components/layout/navigation/wallet-session-id/index.ts +./src/components/layout/navigation/wallet-session-id/types.ts +./src/components/loading/loading-overlay.tsx +./src/components/onboarding/OPTIMIZATION.md +./src/components/onboarding/Onboarding.tsx +./src/components/onboarding/OnboardingButton.tsx +./src/components/onboarding/OnboardingDialog.tsx +./src/components/onboarding/README.md +./src/components/onboarding/common/background-svg.tsx +./src/components/onboarding/common/index.ts +./src/components/onboarding/common/laconic-icon-lettering.tsx +./src/components/onboarding/common/onboarding-container.tsx +./src/components/onboarding/common/step-header.tsx +./src/components/onboarding/common/step-navigation.tsx +./src/components/onboarding/configure-step/configure-step.tsx +./src/components/onboarding/configure-step/index.ts +./src/components/onboarding/connect-step/connect-button.tsx +./src/components/onboarding/connect-step/connect-deploy-first-app.tsx +./src/components/onboarding/connect-step/connect-initial.tsx +./src/components/onboarding/connect-step/connect-step.tsx +./src/components/onboarding/connect-step/index.ts +./src/components/onboarding/connect-step/repository-list.tsx +./src/components/onboarding/connect-step/template-list.tsx +./src/components/onboarding/deploy-step/deploy-step.tsx +./src/components/onboarding/deploy-step/index.ts +./src/components/onboarding/index.ts +./src/components/onboarding/sidebar/index.ts +./src/components/onboarding/sidebar/sidebar-nav.tsx +./src/components/onboarding/store.ts +./src/components/onboarding/types.ts +./src/components/onboarding/useOnboarding.ts +./src/components/projects/project/ProjectCard/FixedProjectCard.tsx +./src/components/projects/project/ProjectCard/ProjectCard.tsx +./src/components/projects/project/ProjectCard/ProjectCardActions.tsx +./src/components/projects/project/ProjectCard/ProjectDeploymentInfo.tsx +./src/components/projects/project/ProjectCard/ProjectStatusDot.tsx +./src/components/projects/project/ProjectCard/index.ts +./src/components/projects/project/ProjectSearchBar/ProjectSearchBar.tsx +./src/components/projects/project/ProjectSearchBar/ProjectSearchBarDialog.tsx +./src/components/projects/project/ProjectSearchBar/ProjectSearchBarEmpty.tsx +./src/components/projects/project/ProjectSearchBar/ProjectSearchBarItem.tsx +./src/components/projects/project/ProjectSearchBar/index.ts +./src/components/projects/project/deployments/DeploymentDetailsCard.tsx +./src/components/projects/project/deployments/FilterForm.tsx +./src/components/projects/project/overview/Activity/AuctionCard.tsx +./src/components/projects/project/overview/OverviewInfo.tsx +./src/components/providers.tsx +./src/context/GQLClientContext.tsx +./src/context/OctokitContext.tsx +./src/context/OctokitProviderWithRouter.tsx +./src/context/WalletContext.tsx +./src/context/WalletContextProvider.tsx +./src/context/index.ts +./src/hooks/useRepoData.tsx +./src/lib/utils.ts +./src/middleware.ts +./src/types/common.ts +./src/types/dashboard.ts +./src/types/deployment.ts +./src/types/hooks/.gitkeep +./src/types/hooks/use-mobile.tsx +./src/types/index.ts +./src/types/project.ts +./src/utils/getInitials.ts +./src/utils/time.ts +./standards/architecture/routes.md +./tailwind.config.ts +./tsconfig.json diff --git a/apps/deploy-fe/src/actions/github.ts b/apps/deploy-fe/src/actions/github.ts new file mode 100644 index 0000000..ba43da0 --- /dev/null +++ b/apps/deploy-fe/src/actions/github.ts @@ -0,0 +1,36 @@ +// app/actions/github.ts +'use server' + +import { auth, currentUser } from '@clerk/nextjs/server' +import { Octokit } from '@octokit/rest' +import type { Organization } from '@octokit/webhooks-types' + +export async function getGitHubOrgs() { + const { userId } = await auth() + + if (!userId) { + throw new Error('Unauthorized') + } + + const user = await currentUser() + const githubAccount = user?.externalAccounts.find( + (account) => account.provider === 'github' + ) + + const token = + githubAccount?.provider === 'github' ? githubAccount.externalId : null + + if (!token) { + throw new Error('GitHub not connected') + } + + const octokit = new Octokit({ auth: token }) + const { data } = await octokit.rest.orgs.listForAuthenticatedUser() + + return data.map((org: Organization) => ({ + id: org.id, + name: org.login, + login: org.login, + avatarUrl: org.avatar_url + })) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/documentation/DocumentationPlaceholder.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/documentation/DocumentationPlaceholder.tsx new file mode 100644 index 0000000..c579a9b --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/documentation/DocumentationPlaceholder.tsx @@ -0,0 +1,669 @@ +'use client' + +import { Separator } from '@radix-ui/react-dropdown-menu' +import { Button } from '@workspace/ui/components/button' +import { Input } from '@workspace/ui/components/input' +import { + Sheet, + SheetContent, + SheetTrigger +} from '@workspace/ui/components/sheet' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger +} from '@workspace/ui/components/tabs' +import { + ChevronDown, + Code, + Copy, + Github, + Globe, + Menu, + Moon, + Search, + Sun, + Terminal, + X +} from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useState } from 'react' + +// Add this component after the imports +function ComingSoonOverlay({ routerAction }: { routerAction: () => void }) { + return ( +
+
+ +

Coming Soon

+

+ Our documentation is currently under development. Check back soon for + comprehensive guides and tutorials. +

+
+ +
+
+
+ ) +} + +export default function DocumentationPage() { + const [isMobileNavOpen, setIsMobileNavOpen] = useState(false) + const [isDarkMode, setIsDarkMode] = useState(false) + const router = useRouter() + const toggleDarkMode = () => { + setIsDarkMode(!isDarkMode) + document.documentElement.classList.toggle('dark') + } + + return ( +
+ router.back()} /> + {/* Header */} +
+
+
+ + + Laconic Deploy + + +
+
+
+
+ + +
+
+ +
+
+
+ +
+ {/* Sidebar */} + + + {/* Main content */} +
+
+
+
+ Docs +
+ +
Getting Started
+
+
+

+ Getting Started with Laconic Deploy +

+

+ Learn how to deploy your applications with Laconic Deploy in + minutes. +

+
+ + +
+

+ Laconic Deploy is a modern deployment platform that makes it + easy to deploy your applications to the cloud. With Laconic + Deploy, you can deploy your applications with just a few clicks + or commands. +

+ +

Installation

+

+ To get started with Laconic Deploy, you need to install the + Laconic CLI. You can install it using npm: +

+ +
+
+ + Terminal + +
+
+                  npm install -g laconic-cli
+                
+
+ +

Authentication

+

+ After installing the CLI, you need to authenticate with Laconic + Deploy: +

+ +
+
+ + Terminal + +
+
+                  laconic login
+                
+
+ +

Creating Your First Project

+

+ To create a new project, use the laconic init{' '} + command: +

+ +
+
+ + Terminal + +
+
+                  laconic init my-awesome-project
+                
+
+ +

Deploying Your Application

+

+ Once your project is set up, you can deploy it with a single + command: +

+ +
+
+ + Terminal + +
+
+                  laconic deploy
+                
+
+ +

Configuration

+

+ Laconic Deploy uses a laconic.config.js file to + configure your deployments. Here's an example configuration: +

+ +
+
+ + laconic.config.js + +
+
+                  {`module.exports = {
+  name: 'my-awesome-project',
+  region: 'us-west-1',
+  environment: {
+    NODE_ENV: 'production',
+    API_URL: 'https://api.example.com'
+  },
+  resources: {
+    compute: {
+      type: 'container',
+      size: 'small',
+      port: 3000
+    },
+    database: {
+      type: 'postgres',
+      version: '14'
+    }
+  }
+}`}
+                
+
+ +

Next Steps

+

+ Now that you've deployed your first application, you might want + to explore: +

+ +
    +
  • Setting up custom domains
  • +
  • Configuring environment variables
  • +
  • Setting up CI/CD pipelines
  • +
  • Monitoring and logging
  • +
  • Scaling your application
  • +
+ +
+ + + CLI + API + Dashboard + + +

+ The Laconic CLI provides a powerful command-line interface + for managing your deployments. See the{' '} + + CLI Reference + {' '} + for more information. +

+
+ +

+ The Laconic API allows you to programmatically manage your + deployments. See the{' '} + + API Reference + {' '} + for more information. +

+
+ +

+ The Laconic Dashboard provides a web interface for + managing your deployments. Visit the{' '} + + Dashboard + {' '} + to get started. +

+
+
+
+
+ +
+ + +
+
+ + {/* Table of contents - desktop only */} + +
+
+ +
+
+

+ © {new Date().getFullYear()} Laconic Deploy. All rights reserved. +

+
+ + Terms + + + Privacy + + + Contact + +
+
+
+
+ ) +} + +function MobileNav({ onNavClose }: { onNavClose: () => void }) { + return ( +
+
+ + + Laconic Deploy + + +
+
+
+ +
+ +
+
+ ) +} + +function DocsSidebar({ + mobile = false, + onNavClose +}: { mobile?: boolean; onNavClose?: () => void }) { + const handleLinkClick = () => { + if (mobile && onNavClose) { + onNavClose() + } + } + + return ( +
+
+

+ Getting Started +

+
+ + Introduction + + + Getting Started + + + Installation + + + CLI Setup + +
+
+
+

+ Core Concepts +

+
+ + Projects + + + Environments + + + Deployments + + + Resources + +
+
+
+

+ Guides +

+
+ + Custom Domains + + + Environment Variables + + + CI/CD Integration + + + Monitoring & Logging + +
+
+
+

+ API Reference +

+
+ + Authentication + + + Projects API + + + Deployments API + + + Resources API + +
+
+
+ ) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/documentation/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/documentation/page.tsx new file mode 100644 index 0000000..2c2055e --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/documentation/page.tsx @@ -0,0 +1,12 @@ +// Documentation page for the Deploy platform using Pagewrapper component + +import PageWrapper from '@/components/foundation/page-wrapper/PageWrapper' +import DocumentationPlaceholder from './DocumentationPlaceholder' + +export default function DocumentationPage() { + return ( + + + + ) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/home/loading.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/home/loading.tsx new file mode 100644 index 0000000..0e7311c --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/home/loading.tsx @@ -0,0 +1,6 @@ +'use client' +import { LoadingOverlay } from '@/components/foundation/loading/loading-overlay' + +export default function Loading() { + return +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/home/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/home/page.tsx new file mode 100644 index 0000000..8f2dd8b --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/home/page.tsx @@ -0,0 +1,191 @@ +import { PageWrapper } from '@/components/foundation' +import { auth, currentUser } from '@clerk/nextjs/server' +import { notFound } from 'next/navigation' +import Link from 'next/link' +import { Octokit } from '@octokit/rest' +import { Shapes } from 'lucide-react' +import { Button } from '@workspace/ui/components/button' + +/** + * Dashboard page + * @returns {React.ReactNode} The rendered component + */ +export default async function Page() { + const authenticated = await auth() + const userId = authenticated.userId + + if (!userId) { + return notFound() + } + + try { + const user = await currentUser() + const githubAccount = user?.externalAccounts.find( + (account) => account.provider === 'oauth_github' + ) + + if (!githubAccount) { + return ( + +
+
+
+ +
+
+
+ GitHub Account Not Connected +
+
+ You need to connect your GitHub account to use the dashboard features. + Please visit your user profile in Clerk to connect GitHub. +
+ +
+
+ ) + } + + // NOTE: We're keeping the token approach for now, but aware it's not working + const authToken = githubAccount.accessToken; + + // Try using GitHub token + let octokit; + try { + octokit = new Octokit({ + auth: authToken || process.env.GITHUB_TOKEN + }); + + // Test with a simple request + + // Try listing repositories + const repoResponse = await octokit.repos.listForAuthenticatedUser(); + + return ( + +
+
+ {repoResponse.data.length > 0 ? ( + repoResponse.data.map((repo) => ( +
+

{repo.name}

+

+ {repo.description || 'No description provided'} +

+
+
+ + {repo.default_branch} + +
+ + View on GitHub + +
+
+ )) + ) : ( +
+

No repositories found

+
+ )} +
+
+
+ ); + } catch (authError) { + console.error("GitHub API error:", authError); + return ( + +
+
+
+ +
+
+
+ Failed to access GitHub API +
+
+ {authError.message} +
+
+

This issue may be related to how Clerk is managing the GitHub token.

+

Try reconnecting your GitHub account with the correct permissions.

+
+ +
+
+ ); + } + } catch (error) { + console.error("GitHub authentication error:", error); + return ( + +
+
+ Failed to authenticate with GitHub +
+
+ {error.message} +
+
+
+ ); + } +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/layout.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/layout.tsx new file mode 100644 index 0000000..d315720 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/layout.tsx @@ -0,0 +1,19 @@ +'use client' + +import NavigationWrapper from '@/components/foundation/navigation-wrapper/NavigationWrapper' +import { TopNavigation } from '@/components/foundation/top-navigation' + +interface LayoutProps { + children: React.ReactNode +} + +const Layout: React.FC = ({ children }) => { + return ( + + + {children} + + ) +} + +export default Layout diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(configure)/cf/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(configure)/cf/page.tsx new file mode 100644 index 0000000..a47d879 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(configure)/cf/page.tsx @@ -0,0 +1,227 @@ +'use client' +import { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { PageWrapper } from '@/components/foundation' +import { LoadingOverlay } from '@/components/foundation/loading/loading-overlay' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@workspace/ui/components/card' +import { Input } from '@workspace/ui/components/input' +import { Label } from '@workspace/ui/components/label' +import { Button } from '@workspace/ui/components/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@workspace/ui/components/select' +import { toast } from 'sonner' +import { Stepper } from '@/components/core/stepper/Stepper' +import { useRepoData } from '@/hooks/useRepoData' + +export default function ConfigureDeploymentPage() { + const router = useRouter() + const params = useParams() + const providerParam = params?.provider ? String(params.provider) : 'github' + + // Use the existing useRepoData hook to fetch all repos (empty string for ID means all repos) + const { repoData: repositories, isLoading } = useRepoData('') + + const [selectedRepo, setSelectedRepo] = useState('') + const [selectedBranch, setSelectedBranch] = useState('main') + const [projectName, setProjectName] = useState('') + const [branches, setBranches] = useState(['main']) + const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([ + { key: '', value: '' } + ]) + + // Define stepper values for the existing Stepper component + const stepperValues = [ + { step: 1, label: 'Select Repository', route: '/projects/github/ps/cr/tm/cf' }, + { step: 2, label: 'Configure', route: '/projects/github/ps/cr/cf' }, + { step: 3, label: 'Deploy', route: '/projects/github/ps/cr/dp' }, + { step: 4, label: 'Success', route: '/projects/github/ps/cr/sc' } + ] + + // When a repository is selected, update project name and branch + useEffect(() => { + if (!selectedRepo || !repositories) return + + const repo = repositories.find(r => r.full_name === selectedRepo) + if (repo) { + setProjectName(repo.name) + setSelectedBranch(repo.default_branch) + + // For simplicity, just use the default branch and some common branch names + // In a real implementation, you would fetch branches for the selected repo + setBranches([repo.default_branch, 'develop', 'feature/new-ui']) + } + }, [selectedRepo, repositories]) + + const handleRepoChange = (repo: string) => { + setSelectedRepo(repo) + } + + const handleBranchChange = (branch: string) => { + setSelectedBranch(branch) + } + + const handleProjectNameChange = (e: React.ChangeEvent) => { + setProjectName(e.target.value) + } + + const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => { + const newEnvVars = [...envVars] + newEnvVars[index][field] = value + + // Add a new empty row if the last row has both key and value filled + if ( + index === newEnvVars.length - 1 && + newEnvVars[index].key !== '' && + newEnvVars[index].value !== '' + ) { + newEnvVars.push({ key: '', value: '' }) + } + + setEnvVars(newEnvVars) + } + + const handleSubmit = () => { + if (!selectedRepo || !selectedBranch || !projectName) { + toast.error('Please fill in all required fields') + return + } + + // Filter out empty env vars + const filteredEnvVars = envVars.filter( + envVar => envVar.key.trim() !== '' && envVar.value.trim() !== '' + ) + + // Convert env vars array to object + const environmentVariables = filteredEnvVars.reduce( + (acc, { key, value }) => ({ ...acc, [key]: value }), + {} + ) + + // Find the selected repository to get its URL + const repo = repositories?.find(r => r.full_name === selectedRepo) + + // Store the configuration in session storage to be used in the next step + sessionStorage.setItem( + 'deploymentConfig', + JSON.stringify({ + repositoryUrl: selectedRepo, + repositoryHtmlUrl: repo?.html_url || `https://github.com/${selectedRepo}`, + branch: selectedBranch, + projectName, + environmentVariables + }) + ) + + // Navigate to the deployment page + router.push(`/projects/${providerParam}/ps/cr/dp`) + } + + if (isLoading) { + return + } + + return ( + +
+ {/* Using the existing Stepper component with the correct props */} + + + + + Project Configuration + + Configure your project settings for deployment + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ {envVars.map((envVar, index) => ( +
+ handleEnvVarChange(index, 'key', e.target.value)} + className="flex-1" + /> + handleEnvVarChange(index, 'value', e.target.value)} + className="flex-1" + /> +
+ ))} +
+

+ Environment variables will be securely stored and available during build and runtime. +

+
+
+ + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(deploy)/dp/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(deploy)/dp/page.tsx new file mode 100644 index 0000000..bd619a9 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(deploy)/dp/page.tsx @@ -0,0 +1,270 @@ +'use client' +import { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { PageWrapper } from '@/components/foundation' +import { LoadingOverlay } from '@/components/foundation/loading/loading-overlay' +import { Stepper } from '@/components/core/stepper/Stepper' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@workspace/ui/components/card' +import { Button } from '@workspace/ui/components/button' +import { toast } from 'sonner' +import { Progress } from '@workspace/ui/components/progress' +import { Loader2, CheckCircle, AlertCircle, GitBranch } from 'lucide-react' +import { StopWatch } from '@/components/core/stop-watch' + +interface DeploymentConfig { + repositoryUrl: string; + repositoryHtmlUrl: string; + branch: string; + projectName: string; + environmentVariables: Record; +} + +export default function DeployPage() { + const router = useRouter() + const params = useParams() + const providerParam = params?.provider ? String(params.provider) : 'github' + + const [deploymentConfig, setDeploymentConfig] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isDeploying, setIsDeploying] = useState(false) + const [deploymentStatus, setDeploymentStatus] = useState<'idle' | 'pending' | 'building' | 'ready' | 'error'>('idle') + const [deploymentProgress, setDeploymentProgress] = useState(0) + const [, setElapsedTime] = useState(0) + const [deploymentId, setDeploymentId] = useState('') + + // Define stepper values for the existing Stepper component + const stepperValues = [ + { step: 1, label: 'Select Repository', route: '/projects/github/ps/cr/tm/cf' }, + { step: 2, label: 'Configure', route: '/projects/github/ps/cr/cf' }, + { step: 3, label: 'Deploy', route: '/projects/github/ps/cr/dp' }, + { step: 4, label: 'Success', route: '/projects/github/ps/cr/sc' } + ] + + // Load deployment config from session storage + useEffect(() => { + const storedConfig = sessionStorage.getItem('deploymentConfig') + + if (storedConfig) { + setDeploymentConfig(JSON.parse(storedConfig)) + } else { + toast.error('Deployment configuration not found') + router.push(`/projects/${providerParam}/ps/cr/cf`) + } + + setIsLoading(false) + }, [router, providerParam]) + + // Handle elapsed time updates from StopWatch component + const handleTimeUpdate = (time: number) => { + setElapsedTime(time) + } + + // Simulate deployment process (would connect to your backend in a real implementation) + const startDeployment = () => { + if (!deploymentConfig) { + toast.error('Deployment configuration not found') + return + } + + setIsDeploying(true) + setDeploymentStatus('pending') + setDeploymentProgress(10) + + // Simulate deployment steps with timeouts + setTimeout(() => { + setDeploymentStatus('building') + setDeploymentProgress(40) + + setTimeout(() => { + // 80% chance of success, 20% chance of failure (for demo purposes) + const success = Math.random() < 0.8 + + if (success) { + setDeploymentStatus('ready') + setDeploymentProgress(100) + + // Generate a random ID for the deployment + const id = Math.random().toString(36).substring(2, 10) + setDeploymentId(id) + + // Store deployment details in session storage + const deploymentDetails = { + id, + url: `https://${deploymentConfig.projectName.toLowerCase().replace(/[^a-z0-9]/g, '-')}.laconic.deploy`, + projectId: 'project_' + Math.random().toString(36).substring(2, 10), + projectName: deploymentConfig.projectName, + status: 'ready', + createdAt: new Date().toISOString(), + repository: { + name: deploymentConfig.repositoryUrl.split('/')[1], + url: deploymentConfig.repositoryHtmlUrl || `https://github.com/${deploymentConfig.repositoryUrl}`, + branch: deploymentConfig.branch + } + }; + + sessionStorage.setItem('deploymentResult', JSON.stringify(deploymentDetails)) + + // Navigate to success page after a short delay + setTimeout(() => { + router.push(`/projects/${providerParam}/ps/cr/sc/${id}`) + }, 2000) + } else { + setDeploymentStatus('error') + setDeploymentProgress(100) + } + + setIsDeploying(false) + }, 5000) // 5 seconds for building + }, 3000) // 3 seconds for pending + } + + const getStatusIcon = () => { + switch (deploymentStatus) { + case 'pending': + return + case 'building': + return + case 'ready': + return + case 'error': + return + default: + return null + } + } + + const getStatusText = () => { + switch (deploymentStatus) { + case 'pending': + return 'Preparing deployment...' + case 'building': + return 'Building your project...' + case 'ready': + return 'Deployment successful!' + case 'error': + return 'Deployment failed' + default: + return 'Ready to deploy' + } + } + + if (isLoading) { + return + } + + return ( + +
+ {/* Using the existing Stepper component with the correct props */} + + + + + Deployment + + Deploy your project to Laconic's decentralized hosting + + + + {deploymentConfig && ( +
+
+

Repository

+
+ +

+ {deploymentConfig.repositoryUrl} ({deploymentConfig.branch}) +

+
+
+
+

Project Name

+

+ {deploymentConfig.projectName} +

+
+ {Object.keys(deploymentConfig.environmentVariables || {}).length > 0 && ( +
+

Environment Variables

+

+ {Object.keys(deploymentConfig.environmentVariables).length} environment variables configured +

+
+ )} +
+ )} + + {deploymentStatus === 'idle' ? ( +
+

+ Ready to deploy your project? Click the button below to start the deployment process. +

+ +
+ ) : ( +
+
+
+ {getStatusIcon()} +

{getStatusText()}

+
+ {/* Using your existing StopWatch component */} + +
+ +

+ {deploymentStatus === 'pending' && 'Setting up the deployment environment...'} + {deploymentStatus === 'building' && 'Building your application...'} + {deploymentStatus === 'ready' && 'Deployment completed successfully!'} + {deploymentStatus === 'error' && 'There was an error deploying your application. Please try again.'} +

+
+ )} +
+ + + {deploymentStatus === 'ready' && ( + + )} + {deploymentStatus === 'error' && ( + + )} + +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(success)/sc/[id]/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(success)/sc/[id]/page.tsx new file mode 100644 index 0000000..11122f7 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(success)/sc/[id]/page.tsx @@ -0,0 +1,258 @@ +'use client' +import { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { PageWrapper } from '@/components/foundation' +import { LoadingOverlay } from '@/components/foundation/loading/loading-overlay' +import { Stepper } from '@/components/core/stepper/Stepper' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@workspace/ui/components/card' +import { Button } from '@workspace/ui/components/button' +import { toast } from 'sonner' +import { CheckCircle, Copy, ExternalLink, Clock } from 'lucide-react' +import Link from 'next/link' +import { relativeTimeMs } from '@/utils/time' +import { getInitials } from '@/utils/getInitials' +import { Avatar, AvatarFallback } from '@workspace/ui/components/avatar' + +interface DeploymentDetails { + id: string; + url: string; + projectId: string; + projectName: string; + status: string; + createdAt: string; + repository: { + name: string; + url: string; + branch: string; + }; +} + +export default function SuccessPage({ params }: { params: { id: string } }) { + const router = useRouter() + const paramsObj = useParams() + const providerParam = paramsObj?.provider ? String(paramsObj.provider) : 'github' + + const [isLoading, setIsLoading] = useState(true) + const [deployment, setDeployment] = useState(null) + const deploymentId = params.id + + // Define stepper values for the existing Stepper component + const stepperValues = [ + { step: 1, label: 'Select Repository', route: '/projects/github/ps/cr/tm/cf' }, + { step: 2, label: 'Configure', route: '/projects/github/ps/cr/cf' }, + { step: 3, label: 'Deploy', route: '/projects/github/ps/cr/dp' }, + { step: 4, label: 'Success', route: '/projects/github/ps/cr/sc' } + ] + + // Get deployment details from session storage + useEffect(() => { + // For now, we'll get the deployment details from session storage + // In a real app, you'd fetch this from your API + const storedDeployment = sessionStorage.getItem('deploymentResult') + + if (storedDeployment) { + setDeployment(JSON.parse(storedDeployment)) + } else { + // If not found in session storage, simulate it (for demo purposes) + // In a real app, you'd fetch from the API using the ID + simulateDeploymentDetails() + } + + setIsLoading(false) + }, [deploymentId]) + + // Simulate deployment details if needed (for demo purposes) + const simulateDeploymentDetails = () => { + const mockDeployment: DeploymentDetails = { + id: deploymentId, + url: `https://project-${deploymentId}.laconic.deploy`, + projectId: 'project_' + Math.random().toString(36).substring(2, 10), + projectName: 'Demo Project', + status: 'ready', + createdAt: new Date().toISOString(), + repository: { + name: 'demo-repo', + url: 'https://github.com/yourusername/demo-repo', + branch: 'main' + } + } + + setDeployment(mockDeployment) + } + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text) + toast.success('Copied to clipboard') + } + + if (isLoading) { + return + } + + if (!deployment) { + return ( + +
+ + +
+

+ We couldn't find the deployment you're looking for. It may have been deleted or expired. +

+ +
+
+
+
+
+ ) + } + + // Calculate relative time for the deployment + const deploymentTime = new Date(deployment.createdAt).getTime() + const deployedBy = 'You' // In a real app, you'd get this from the deployment data + + return ( + +
+ {/* Using the existing Stepper component with the correct props */} + + + + +
+ + Deployment Successful +
+ + Your project has been successfully deployed to Laconic's decentralized hosting + +
+ +
+

Deployment URL

+
+ + {deployment.url} + + + +
+
+ +
+
+

Project Details

+
    +
  • + Project Name:{' '} + {deployment.projectName} +
  • +
  • + Repository:{' '} + {deployment.repository.name} +
  • +
  • + Branch:{' '} + {deployment.repository.branch} +
  • +
  • + Deployment ID:{' '} + {deployment.id} +
  • +
+
+
+

Deployment Information

+
    +
  • + Status:{' '} + + {deployment.status.toUpperCase()} + +
  • +
  • +
    + + Deployed at:{' '} + {relativeTimeMs(deploymentTime)} +
    +
  • +
  • +
    + Deployed by:{' '} + + + {getInitials(deployedBy)} + + {deployedBy} + +
    +
  • +
+
+
+ +
+

What's Next?

+
    +
  • • Configure custom domains for your deployment
  • +
  • • Set up automatic deployments for new commits
  • +
  • • Add collaborators to your project
  • +
  • • Monitor deployment performance and analytics
  • +
+
+
+ + + + +
+
+
+ ) \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(template)/tm/(configure)/cf/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(template)/tm/(configure)/cf/page.tsx new file mode 100644 index 0000000..85cd9ce --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(template)/tm/(configure)/cf/page.tsx @@ -0,0 +1,12 @@ +const Page = () => { + return ( +
+

+ Hello from + (web3-authenticated)/(dashboard)/(projects)/pr/[provider]/[orgSlug]/(projects)/ps/(create)/cr/(template)/tm/(configure)/cf +

+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(template)/tm/(deploy)/dp/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(template)/tm/(deploy)/dp/page.tsx new file mode 100644 index 0000000..fdbc735 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(template)/tm/(deploy)/dp/page.tsx @@ -0,0 +1,16 @@ +import { PageWrapper } from '@/components/foundation' + +const Page = () => { + return ( + +
+

+ Hello from + (web3-authenticated)/(dashboard)/(projects)/pr/[provider]/[orgSlug]/(projects)/ps/(create)/cr/(template)/tm/(deploy)/dp +

+
+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(deployments)/dep/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(deployments)/dep/page.tsx new file mode 100644 index 0000000..5c97da1 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(deployments)/dep/page.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { PageWrapper } from '@/components/foundation'; +import { DeploymentDetailsCard } from '@/components/projects/project/deployments/DeploymentDetailsCard'; +import { FilterForm } from '@/components/projects/project/deployments/FilterForm'; +import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; +import { IconButton } from '@workspace/ui/components/button'; +import { Rocket } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { useRepoData } from '@/hooks/useRepoData'; +import type { Deployment, Domain } from '@/types'; + +export default function DeploymentsPage() { + const router = useRouter(); + const params = useParams(); + // Safely unwrap params + const id = params?.id ? String(params.id) : ''; + const provider = params?.provider ? String(params.provider) : ''; + + // Use the hook to get repo data + const { repoData } = useRepoData(id); + + // Mock deployments data - in a real app, you would fetch this from an API + const [deployments, setDeployments] = useState([]); + const [filteredDeployments, setFilteredDeployments] = useState([]); + const [prodBranchDomains, setProdBranchDomains] = useState([]); + + // Create a default deployment + const defaultDeployment: Deployment = { + id: 'default', + branch: 'main', + status: 'COMPLETED', + isCurrent: true, + createdAt: Date.now() - 24 * 60 * 60 * 1000, // 1 day ago + applicationDeploymentRecordData: { + url: repoData ? `https://${repoData.name.toLowerCase()}.example.com` : 'https://example.com' + }, + createdBy: { + name: repoData?.owner?.login || 'username' + } + }; + + const secondDeployment: Deployment = { + id: 'previous', + branch: 'feature/new-ui', + status: 'COMPLETED', + isCurrent: false, + createdAt: Date.now() - 3 * 24 * 60 * 60 * 1000, // 3 days ago + applicationDeploymentRecordData: { + url: repoData ? `https://dev.${repoData.name.toLowerCase()}.example.com` : 'https://dev.example.com' + }, + createdBy: { + name: repoData?.owner?.login || 'username' + } + }; + + // Initialize with mock data + useEffect(() => { + const mockDeployments = [defaultDeployment, secondDeployment]; + setDeployments(mockDeployments); + setFilteredDeployments(mockDeployments); + + // Mock domains + const mockDomains: Domain[] = [ + { + id: '1', + name: repoData ? `${repoData.name.toLowerCase()}.example.com` : 'example.com', + status: 'ACTIVE', + isCustom: false + } + ]; + setProdBranchDomains(mockDomains); + }, [repoData]); + + // Handle tab changes by navigating to the correct folder + const handleTabChange = (value: string) => { + const basePath = `/projects/${provider}/ps/${id}`; + + switch (value) { + case 'overview': + router.push(basePath); + break; + case 'deployment': + router.push(`${basePath}/dep`); + break; + case 'settings': + router.push(`${basePath}/set`); + break; + case 'git': + router.push(`${basePath}/int`); + break; + case 'env-vars': + router.push(`${basePath}/set/env`); + break; + } + }; + + // Reset filters handler + const handleResetFilters = () => { + setFilteredDeployments(deployments); + }; + + const project = { + id: id, + prodBranch: 'main', + name: repoData?.name || 'Project' + }; + + const currentDeployment = deployments.find(deployment => deployment.isCurrent) || defaultDeployment; + + return ( + +
+ {/* Tabs navigation */} + + + Overview + Deployment + Settings + Git + Environment Variables + + + +
+ +
+ {filteredDeployments.length > 0 ? ( + filteredDeployments.map((deployment) => ( + + )) + ) : ( +
+
+

+ No deployments found +

+

+ Please change your search query or filters. +

+
+ } + onClick={handleResetFilters} + > + RESET FILTERS + +
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(integrations)/int/GitPage.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(integrations)/int/GitPage.tsx new file mode 100644 index 0000000..9c03b46 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(integrations)/int/GitPage.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay"; + +interface SwitchProps { + id: string; + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; +} + +function Switch({ id, checked, onChange, disabled = false }: SwitchProps) { + return ( + + ); +} + +export default function GitPage() { + const params = useParams(); + const { provider, id } = params; + + const [pullRequestComments, setPullRequestComments] = useState(true); + const [commitComments, setCommitComments] = useState(false); + const [productionBranch, setProductionBranch] = useState("main"); + const [webhookUrl, setWebhookUrl] = useState(""); + const [isSavingBranch, setIsSavingBranch] = useState(false); + const [isSavingWebhook, setIsSavingWebhook] = useState(false); + + const handleSaveBranch = async () => { + try { + setIsSavingBranch(true); + // Save production branch + console.log("Saving production branch:", productionBranch); + // Implement API call to save production branch + await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call + + // Show success notification + } catch (error) { + console.error("Failed to save production branch:", error); + // Show error notification + } finally { + setIsSavingBranch(false); + } + }; + + const handleSaveWebhook = async () => { + try { + setIsSavingWebhook(true); + // Save webhook URL + console.log("Saving webhook URL:", webhookUrl); + // Implement API call to save webhook URL + await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call + + // Show success notification + } catch (error) { + console.error("Failed to save webhook URL:", error); + // Show error notification + } finally { + setIsSavingWebhook(false); + } + }; + + return ( + <> + {(isSavingBranch || isSavingWebhook) && } + +
+
+

Git repository

+ +
+
+
+
+ + +
+

+ Laconic will comment on pull requests opened against this project. +

+
+
+ +
+
+
+ + +
+

+ Laconic will comment on commits deployed to production. +

+
+
+
+
+ +
+

Production branch

+ +

+ By default, each commit pushed to the main branch initiates a production deployment. You can opt for a + different branch for deployment in the settings. +

+ +
+
+ + setProductionBranch(e.target.value)} + className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white" + /> +
+ + +
+
+ +
+

Deploy webhooks

+ +

+ Webhooks configured to trigger when there is a change in a project's build or deployment status. +

+ +
+
+ +
+ setWebhookUrl(e.target.value)} + placeholder="https://" + className="flex-1 px-3 py-2 rounded-l-md bg-gray-900 border border-gray-700 text-white" + /> + +
+
+
+
+
+ + ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(integrations)/int/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(integrations)/int/page.tsx new file mode 100644 index 0000000..4c493bc --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(integrations)/int/page.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { PageWrapper } from "@/components/foundation"; +import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; +import GitPage from "./GitPage"; +import { useRouter } from 'next/navigation'; +import { useRepoData } from '@/hooks/useRepoData'; + +export default function GitIntegrationsPage() { + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + const provider = params.provider as string; + // Use the hook to get repo data + const { repoData } = useRepoData(id); + + // Handle tab changes by navigating to the correct folder + const handleTabChange = (value: string) => { + const basePath = `/projects/${provider}/ps/${id}`; + + switch (value) { + case 'overview': + router.push(basePath); + break; + case 'deployment': + router.push(`${basePath}/dep`); + break; + case 'settings': + router.push(`${basePath}/set`); + break; + case 'git': + router.push(`${basePath}/int`); + break; + case 'env-vars': + router.push(`${basePath}/set/env`); + break; + } + }; + + return ( + +
+ {/* Tabs navigation */} + + + Overview + Deployment + Settings + Git + Environment Variables + + + + {/* Git content */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(collaborators)/col/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(collaborators)/col/page.tsx new file mode 100644 index 0000000..cb6805a --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(collaborators)/col/page.tsx @@ -0,0 +1,66 @@ +'use client' +import { PageWrapper } from "@/components/foundation" +import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs' +import { useRouter } from 'next/navigation' + +interface PageProps { + params: { + id: string + provider: string + orgSlug: string + } +} + +const Page = ({ params }: PageProps) => { + const router = useRouter(); + // Mock data for the project + + // Handle tab changes by navigating to the correct folder + const handleTabChange = (value: string) => { + const basePath = `/projects/${params.provider}/ps/${params.id}`; + + switch (value) { + case 'overview': + router.push(basePath); + break; + case 'deployment': + router.push(`${basePath}/dep`); + break; + case 'settings': + router.push(`${basePath}/set`); + break; + case 'git': + router.push(`${basePath}/int`); + break; + case 'env-vars': + router.push(`${basePath}/set/env`); + break; + } + }; + + return ( + +
{/* Take full width in bento grid */} + {/* Tabs navigation */} + + + Overview + Deployment + Settings + Git + Environment Variables + + +
+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(domains)/dom/(add)/cf/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(domains)/dom/(add)/cf/page.tsx new file mode 100644 index 0000000..62369b0 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(domains)/dom/(add)/cf/page.tsx @@ -0,0 +1,12 @@ +const Page = () => { + return ( +
+

+ Hello from + (web3-authenticated)/(dashboard)/(projects)/pr/[provider]/[orgSlug]/(projects)/ps/[id]/(settings)/set/(domains)/dom/(add)/cf +

+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(domains)/dom/(add)/config/cf/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(domains)/dom/(add)/config/cf/page.tsx new file mode 100644 index 0000000..6246ab7 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(domains)/dom/(add)/config/cf/page.tsx @@ -0,0 +1,12 @@ +const Page = () => { + return ( +
+

+ Hello from + (web3-authenticated)/(dashboard)/(projects)/pr/[provider]/[orgSlug]/(projects)/ps/[id]/(settings)/set/(domains)/dom/(add)/config/cf +

+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(environment-variables)/env/EnvVarsPage.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(environment-variables)/env/EnvVarsPage.tsx new file mode 100644 index 0000000..67718a3 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(environment-variables)/env/EnvVarsPage.tsx @@ -0,0 +1,415 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay"; +import { PlusIcon, ChevronDownIcon, ChevronUpIcon, PencilIcon, TrashIcon } from "lucide-react"; + +interface EnvVarItem { + key: string; + value: string; + isEditing?: boolean; +} + +interface EnvGroupProps { + title: string; + isOpen: boolean; + onToggle: () => void; + children: React.ReactNode; + varCount: number; +} + +function EnvGroup({ title, isOpen, onToggle, children, varCount }: EnvGroupProps) { + return ( +
+
+
+

{title}

+ ({varCount}) +
+ +
+ {isOpen && ( +
+ {children} +
+ )} +
+ ); +} + +export default function EnvVarsPage() { + const params = useParams(); + const { provider, id } = params; + + const [isAddingVar, setIsAddingVar] = useState(false); + const [newVarKey, setNewVarKey] = useState(""); + const [newVarValue, setNewVarValue] = useState(""); + const [isSaving, setIsSaving] = useState(false); + + // Group states + const [productionOpen, setProductionOpen] = useState(true); + const [previewOpen, setPreviewOpen] = useState(true); + const [deploymentOpen, setDeploymentOpen] = useState(true); + + // Environment variables + const [productionVars, setProductionVars] = useState([]); + const [previewVars, setPreviewVars] = useState([ + { key: "TEST_KEY", value: "1" } + ]); + const [deploymentVars, setDeploymentVars] = useState([]); + + // Checkboxes for environment selection + const [envSelection, setEnvSelection] = useState({ + production: true, + preview: true, + development: true + }); + + const handleEnvSelectionChange = (env: 'production' | 'preview' | 'development') => { + setEnvSelection({ + ...envSelection, + [env]: !envSelection[env] + }); + }; + + const addVariable = () => { + if (!newVarKey.trim() || !newVarValue.trim()) return; + + const newVar = { key: newVarKey, value: newVarValue }; + + if (envSelection.production) { + setProductionVars([...productionVars, { ...newVar }]); + } + + if (envSelection.preview) { + setPreviewVars([...previewVars, { ...newVar }]); + } + + if (envSelection.development) { + setDeploymentVars([...deploymentVars, { ...newVar }]); + } + + // Reset form + setNewVarKey(""); + setNewVarValue(""); + setIsAddingVar(false); + }; + + const cancelAddVariable = () => { + setNewVarKey(""); + setNewVarValue(""); + setIsAddingVar(false); + }; + + const removeVariable = (env: 'production' | 'preview' | 'development', index: number) => { + if (env === 'production') { + setProductionVars(productionVars.filter((_, i) => i !== index)); + } else if (env === 'preview') { + setPreviewVars(previewVars.filter((_, i) => i !== index)); + } else if (env === 'development') { + setDeploymentVars(deploymentVars.filter((_, i) => i !== index)); + } + }; + + const editVariable = (env: 'production' | 'preview' | 'development', index: number) => { + if (env === 'production') { + const updatedVars = [...productionVars]; + updatedVars[index] = { ...updatedVars[index], isEditing: true }; + setProductionVars(updatedVars); + } else if (env === 'preview') { + const updatedVars = [...previewVars]; + updatedVars[index] = { ...updatedVars[index], isEditing: true }; + setPreviewVars(updatedVars); + } else if (env === 'development') { + const updatedVars = [...deploymentVars]; + updatedVars[index] = { ...updatedVars[index], isEditing: true }; + setDeploymentVars(updatedVars); + } + }; + + const updateVariable = (env: 'production' | 'preview' | 'development', index: number, key: string, value: string) => { + if (env === 'production') { + const updatedVars = [...productionVars]; + updatedVars[index] = { key, value, isEditing: false }; + setProductionVars(updatedVars); + } else if (env === 'preview') { + const updatedVars = [...previewVars]; + updatedVars[index] = { key, value, isEditing: false }; + setPreviewVars(updatedVars); + } else if (env === 'development') { + const updatedVars = [...deploymentVars]; + updatedVars[index] = { key, value, isEditing: false }; + setDeploymentVars(updatedVars); + } + }; + + const saveChanges = async () => { + try { + setIsSaving(true); + // Save environment variables + console.log("Saving environment variables:", { + production: productionVars, + preview: previewVars, + deployment: deploymentVars + }); + // Implement API call to save environment variables + await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call + + // Show success notification + } catch (error) { + console.error("Failed to save environment variables:", error); + // Show error notification + } finally { + setIsSaving(false); + } + }; + + const renderEnvVarRow = (env: 'production' | 'preview' | 'development', variable: EnvVarItem, index: number) => { + if (variable.isEditing) { + return ( +
+ { + const updatedVars = env === 'production' + ? [...productionVars] + : env === 'preview' + ? [...previewVars] + : [...deploymentVars]; + updatedVars[index] = { ...updatedVars[index], key: e.target.value }; + if (env === 'production') setProductionVars(updatedVars); + else if (env === 'preview') setPreviewVars(updatedVars); + else setDeploymentVars(updatedVars); + }} + placeholder="KEY" + /> + { + const updatedVars = env === 'production' + ? [...productionVars] + : env === 'preview' + ? [...previewVars] + : [...deploymentVars]; + updatedVars[index] = { ...updatedVars[index], value: e.target.value }; + if (env === 'production') setProductionVars(updatedVars); + else if (env === 'preview') setPreviewVars(updatedVars); + else setDeploymentVars(updatedVars); + }} + placeholder="Value" + /> + + +
+ ); + } + + return ( +
+
+ {variable.key} + {variable.value} +
+
+ + +
+
+ ); + }; + + return ( + <> + {isSaving && } + +
+
+

Environment Variables

+

+ A new deployment is required for your changes to take effect. +

+ + {!isAddingVar ? ( + + ) : ( +
+
+
+ + setNewVarKey(e.target.value)} + className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white" + placeholder="KEY" + /> +
+
+ + setNewVarValue(e.target.value)} + className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white" + placeholder="Value" + /> +
+
+ +
+ +
+
+ handleEnvSelectionChange('production')} + className="mr-2" + /> + +
+
+ handleEnvSelectionChange('preview')} + className="mr-2" + /> + +
+
+ handleEnvSelectionChange('development')} + className="mr-2" + /> + +
+
+
+ +
+ + +
+
+ )} + +
+ setProductionOpen(!productionOpen)} + varCount={productionVars.length} + > + {productionVars.length > 0 ? ( +
+ {productionVars.map((variable, index) => renderEnvVarRow('production', variable, index))} +
+ ) : ( +

No variables defined

+ )} +
+ + setPreviewOpen(!previewOpen)} + varCount={previewVars.length} + > + {previewVars.length > 0 ? ( +
+ {previewVars.map((variable, index) => renderEnvVarRow('preview', variable, index))} +
+ ) : ( +

No variables defined

+ )} +
+ + setDeploymentOpen(!deploymentOpen)} + varCount={deploymentVars.length} + > + {deploymentVars.length > 0 ? ( +
+ {deploymentVars.map((variable, index) => renderEnvVarRow('development', variable, index))} +
+ ) : ( +

No variables defined

+ )} +
+
+ +
+ +
+
+
+ + ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(environment-variables)/env/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(environment-variables)/env/page.tsx new file mode 100644 index 0000000..b8a2b8c --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(environment-variables)/env/page.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { PageWrapper } from "@/components/foundation"; +import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; +import EnvVarsPage from "./EnvVarsPage"; +import { useRouter } from 'next/navigation'; +import { useRepoData } from '@/hooks/useRepoData'; + +export default function EnvironmentVariablesPage() { + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + const provider = params.provider as string; + + // Use the hook to get repo data + const { repoData } = useRepoData(id); + + // Handle tab changes by navigating to the correct folder + const handleTabChange = (value: string) => { + const basePath = `/projects/${provider}/ps/${id}`; + + switch (value) { + case 'overview': + router.push(basePath); + break; + case 'deployment': + router.push(`${basePath}/dep`); + break; + case 'settings': + router.push(`${basePath}/set`); + break; + case 'git': + router.push(`${basePath}/int`); + break; + case 'env-vars': + router.push(`${basePath}/set/env`); + break; + } + }; + + return ( + +
+ {/* Tabs navigation */} + + + Overview + Deployment + Settings + Git + Environment Variables + + + + {/* Environment Variables content */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(git)/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(git)/page.tsx new file mode 100644 index 0000000..1ca5673 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(git)/page.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { PageWrapper } from "@/components/foundation"; +import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; +import GitPage from "../../../(integrations)/int/GitPage"; +import { useRouter } from 'next/navigation'; +import { useRepoData } from '@/hooks/useRepoData'; + +export default function GitIntegrationsPage() { + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + const provider = params.provider as string; + // Use the hook to get repo data + const { repoData } = useRepoData(id); + + // Handle tab changes by navigating to the correct folder + const handleTabChange = (value: string) => { + const basePath = `/projects/${provider}/ps/${id}`; + + switch (value) { + case 'overview': + router.push(basePath); + break; + case 'deployment': + router.push(`${basePath}/dep`); + break; + case 'settings': + router.push(`${basePath}/set`); + break; + case 'git': + router.push(`${basePath}/int`); + break; + case 'env-vars': + router.push(`${basePath}/set/env`); + break; + } + }; + + return ( + +
+ {/* Tabs navigation */} + + + Overview + Deployment + Settings + Git + Environment Variables + + + + {/* Git content */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/ProjectSettingsPage.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/ProjectSettingsPage.tsx new file mode 100644 index 0000000..74f8b56 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/ProjectSettingsPage.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { Clipboard } from "lucide-react"; +import { Dropdown } from "@/components/core/dropdown"; +import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay"; +import { useRepoData } from "@/hooks/useRepoData"; + +// Create a simple modal component +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + footer?: React.ReactNode; +} + +function Modal({ isOpen, onClose, title, children, footer }: ModalProps) { + if (!isOpen) return null; + + return ( +
+
+
+

{title}

+ +
+
{children}
+ {footer &&
{footer}
} +
+
+ ); +} + +export default function ProjectSettingsPage() { + const router = useRouter(); + const params = useParams(); + const id = params?.id ? String(params.id) : ''; + + // Use the hook to get repo data + const { repoData, isLoading } = useRepoData(id); + + const [projectName, setProjectName] = useState(""); + const [projectDescription, setProjectDescription] = useState(""); + const [projectId, setProjectId] = useState(""); + const [selectedAccount, setSelectedAccount] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [isTransferring, setIsTransferring] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + // Update form values when project data is loaded + useEffect(() => { + if (repoData) { + setProjectName(repoData.name || ""); + setProjectDescription(repoData.description || ""); + setProjectId(repoData.id?.toString() || ""); + } + }, [repoData]); + + const accountOptions = [ + { label: "Personal Account", value: "account1" }, + { label: "Team Account", value: "account2" } + ]; + + const handleSave = async () => { + try { + setIsSaving(true); + console.log("Saving project info:", { projectName, projectDescription }); + + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Show success notification - in a real app you'd use a toast library + console.log("Project updated successfully"); + } catch (error) { + console.error("Failed to save project info:", error); + // Show error notification + } finally { + setIsSaving(false); + } + }; + + const handleTransfer = async () => { + try { + setIsTransferring(true); + // Transfer project to selected account + console.log("Transferring project to:", selectedAccount); + // Implement API call to transfer project + await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call + + // After successful transfer, navigate back to projects list + router.push("/dashboard/projects"); + } catch (error) { + console.error("Failed to transfer project:", error); + // Show error notification + } finally { + setIsTransferring(false); + } + }; + + const handleDelete = async () => { + try { + setIsDeleting(true); + // Delete project + console.log("Deleting project"); + // Implement API call to delete project + await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call + + // After successful deletion, navigate back to projects list + router.push("/dashboard/projects"); + } catch (error) { + console.error("Failed to delete project:", error); + // Show error notification + } finally { + setIsDeleting(false); + setIsDeleteModalOpen(false); + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + }; + + const DeleteModalFooter = ( +
+ + +
+ ); + + if (isLoading) { + return ; + } + + return ( + <> + {(isSaving || isTransferring || isDeleting) && } + +
+
+

Project Info

+ +
+
+ + setProjectName(e.target.value)} + className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white" + /> +
+ +
+ + setProjectDescription(e.target.value)} + className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white" + /> +
+ +
+ +
+ + +
+
+ + +
+
+ +
+

Transfer Project

+ +
+ + setSelectedAccount(value)} + className="w-full" + /> + +

+ Transfer this app to your personal account or a team you are a member of. +

+ + +
+
+ +
+

Delete Project

+ +

+ The project will be permanently deleted, including its deployments and domains. This action is + irreversible and cannot be undone. +

+ + + + !isDeleting && setIsDeleteModalOpen(false)} + title="Are you absolutely sure?" + footer={DeleteModalFooter} + > +

+ This action cannot be undone. This will permanently delete the project + and all associated deployments and domains. +

+
+
+
+ + ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/page.tsx new file mode 100644 index 0000000..71b7a78 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/page.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { PageWrapper } from "@/components/foundation"; +import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; +import ProjectSettingsPage from "./ProjectSettingsPage"; +import { useRouter } from 'next/navigation'; +import { useRepoData } from '@/hooks/useRepoData'; + +export default function SettingsPage() { + const router = useRouter(); + const params = useParams(); + // Safely unwrap params + const id = params?.id ? String(params.id) : ''; + const provider = params?.provider ? String(params.provider) : ''; + + // Use the hook to get repo data + const { repoData } = useRepoData(id); + + // Handle tab changes by navigating to the correct folder + const handleTabChange = (value: string) => { + const basePath = `/projects/${provider}/ps/${id}`; + + switch (value) { + case 'overview': + router.push(basePath); + break; + case 'deployment': + router.push(`${basePath}/dep`); + break; + case 'settings': + router.push(`${basePath}/set`); + break; + case 'git': + router.push(`${basePath}/int`); + break; + case 'env-vars': + router.push(`${basePath}/set/env`); + break; + } + }; + + return ( + +
+ {/* Tabs navigation */} + + + Overview + Deployment + Settings + Git + Environment Variables + + + + {/* Settings content */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/deployments/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/deployments/page.tsx new file mode 100644 index 0000000..1c65931 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/deployments/page.tsx @@ -0,0 +1,159 @@ +'use client' +import { useEffect, useState } from 'react' +import { PageWrapper } from '@/components/foundation' +import { FixedProjectCard } from '@/components/projects/project/ProjectCard/FixedProjectCard' +import { Button } from '@workspace/ui/components/button' +import { Shapes } from 'lucide-react' +import { useAuth } from '@clerk/nextjs' + +interface Deployment { + id: string + name: string + repositoryId: string + status: 'running' | 'complete' | 'failed' + url?: string + branch: string + createdAt: string + createdBy: { + name: string + } +} + +export default function ProjectsPage() { + const [deployments, setDeployments] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const { isLoaded: isAuthLoaded, userId } = useAuth() + + useEffect(() => { + async function fetchDeployments() { + if (!isAuthLoaded) { + return; + } + + setIsLoading(true); + + try { + if (!userId) { + setError('Not authenticated'); + return; + } + + // In a real implementation, you would query your GraphQL backend + // For now, we'll mock some deployments + const mockDeployments: Deployment[] = [ + { + id: 'dep_abc123', + name: 'My Project', + repositoryId: '123456', + status: 'complete', + url: 'https://my-project.example.com', + branch: 'main', + createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + createdBy: { + name: 'John Doe' + } + }, + { + id: 'dep_def456', + name: 'Another Project', + repositoryId: '789012', + status: 'running', + branch: 'develop', + createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + createdBy: { + name: 'Jane Smith' + } + } + ]; + + setDeployments(mockDeployments); + } catch (err) { + console.error('Error fetching deployments:', err) + setError('Failed to fetch deployments') + } finally { + setIsLoading(false) + } + } + + fetchDeployments() + }, [isAuthLoaded, userId]); + + return ( + + {isLoading ? ( +
+
+
+ ) : error ? ( +
+
+
+ +
+
+

Error: {error}

+

+ There was an error loading your deployments. +

+ +
+ ) : deployments.length === 0 ? ( +
+
+
+ +
+
+

Deploy your first app

+

+ You haven't deployed any projects yet. Start by importing a repository from your GitHub account. +

+ +
+ ) : ( +
+
+ {deployments.map((deployment) => ( + + ))} +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/layout.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/layout.tsx new file mode 100644 index 0000000..cc27352 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/layout.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react' + +interface LayoutProps { + children: ReactNode + params: { + id: string + provider: string + orgSlug: string + } +} + +export default function ProjectLayout({ children }: LayoutProps) { + return
{children}
+} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/loading.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/loading.tsx new file mode 100644 index 0000000..a7dae9c --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/loading.tsx @@ -0,0 +1,14 @@ +import { PageWrapper } from '@/components/foundation' + +export default function Loading() { + return ( + +
+
+
+
+
+
+ + ) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/page.tsx new file mode 100644 index 0000000..20049ad --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/page.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { PageWrapper } from '@/components/foundation'; +import { getInitials } from '@/utils/getInitials'; +import { relativeTimeMs } from '@/utils/time'; +import { + Avatar, + AvatarFallback} from '@workspace/ui/components/avatar'; +import { Button } from '@workspace/ui/components/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; +import { Activity, Clock, GitBranch, ExternalLink } from 'lucide-react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useRepoData } from '@/hooks/useRepoData'; +import { useEffect, useState } from 'react'; + +export default function ProjectOverviewPage() { + const router = useRouter(); + const params = useParams(); + // Safely unwrap params + const id = params?.id ? String(params.id) : ''; + const provider = params?.provider ? String(params.provider) : ''; + + // Use the hook to get repo data + const { repoData } = useRepoData(id); + + // Default deployment details + const [deploymentUrl, setDeploymentUrl] = useState(''); + const [deploymentDate, setDeploymentDate] = useState(Date.now() - 60 * 60 * 1000); // 1 hour ago + const [deployedBy, setDeployedBy] = useState(''); + const [projectName, setProjectName] = useState(''); + const [branch, setBranch] = useState('main'); + + // Update details when repo data is loaded + useEffect(() => { + if (repoData) { + setProjectName(repoData.name); + setBranch(repoData.default_branch || 'main'); + setDeployedBy(repoData.owner?.login || 'username'); + // Create a deployment URL based on the repo name + setDeploymentUrl(`https://${repoData.name.toLowerCase()}.example.com`); + } + }, [repoData]); + + // Auction data + const auctionId = 'laconic1sdfjwei4jfkasifgjiai45ioasjf5jjjafij355'; + + // Activities data + const activities = [ + { + username: deployedBy || 'username', + branch: branch, + action: 'deploy: source cargo', + time: '5 minutes ago' + }, + { + username: deployedBy || 'username', + branch: branch, + action: 'bump', + time: '5 minutes ago' + }, + { + username: deployedBy || 'username', + branch: branch, + action: 'version: update version', + time: '5 minutes ago' + }, + { + username: deployedBy || 'username', + branch: branch, + action: 'build: updates', + time: '5 minutes ago' + } + ]; + + // Handle tab changes by navigating to the correct folder + const handleTabChange = (value: string) => { + const basePath = `/projects/${provider}/ps/${id}`; + + switch (value) { + case 'overview': + router.push(basePath); + break; + case 'deployment': + router.push(`${basePath}/dep`); + break; + case 'settings': + router.push(`${basePath}/set`); + break; + case 'git': + router.push(`${basePath}/int`); + break; + case 'env-vars': + router.push(`${basePath}/set/env`); + break; + } + }; + + return ( + +
{/* Take full width in bento grid */} + {/* Tabs navigation */} + + + Overview + Deployment + Settings + Git + Environment Variables + + + + {/* Main content card (containing project info and auction details) */} +
+ {/* Project info section */} +
+
+ + {getInitials(projectName || '')} + +
+

{projectName}

+

+ {deploymentUrl.replace(/^https?:\/\//, '')} +

+
+
+ +
+
+
+ + Source +
+
+ + {branch} +
+
+ +
+
+ + Deployment URL +
+ + {deploymentUrl} + +
+
+ +
+
+ + Deployment date +
+
+ + {relativeTimeMs(deploymentDate)} + + by + + {getInitials(deployedBy)} + + {deployedBy} +
+
+ + {/* Divider between project info and auction details */} +
+ + {/* Auction Details section */} +
+

Auction Details

+ +
+
+

Auction ID

+

{auctionId}

+
+
+

Auction Status

+
+ COMPLETED +
+
+
+ +
+
+

Deployer LRNs

+

{auctionId}

+
+
+

Deployer Funds Status

+
+ RELEASED +
+
+
+ +
+ +
+
+
+
+ + {/* Activity section - not in a card */} +
+

+ + Activity +

+ +
+ {activities.map((activity, index) => ( +
+
+
+ {activity.username} + + {activity.branch} + {activity.action} +
+
{activity.time}
+
+ ))} +
+
+
+ + {/* These content sections won't be shown - we'll navigate to respective pages instead */} + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/error.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/error.tsx new file mode 100644 index 0000000..79eb833 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/error.tsx @@ -0,0 +1,30 @@ +'use client' + +import { Button } from '@workspace/ui/components/button' +import { useEffect } from 'react' + +export default function ClientError({ + error, + reset +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error(error) + }, [error]) + + return ( +
+

Something went wrong!

+

{error.message}

+ +
+ ) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/loading.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/loading.tsx new file mode 100644 index 0000000..cf2e39c --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/loading.tsx @@ -0,0 +1,11 @@ +export default function Loading() { + return ( +
+
+
+ +
+
+
+ ) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/page.tsx new file mode 100644 index 0000000..efbd9e4 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/page.tsx @@ -0,0 +1,161 @@ +'use client' +import { PageWrapper } from '@/components/foundation' +import CheckBalanceIframe from '@/components/iframe/check-balance-iframe/CheckBalanceIframe' +import type { Project } from '@octokit/webhooks-types' +import { FixedProjectCard } from '@/components/projects/project/ProjectCard/FixedProjectCard' +import { Button } from '@workspace/ui/components/button' +import { useEffect, useState } from 'react' +import { Shapes } from 'lucide-react' +import { useAuth, useUser } from '@clerk/nextjs' +import { useRepoData } from '@/hooks/useRepoData' + +interface ProjectData { + id: string + name: string + icon?: string + deployments: any[] + // Additional fields from GitHub repo + full_name?: string + html_url?: string + updated_at?: string + default_branch?: string +} + +export default function ProjectsPage() { + const [, setIsBalanceSufficient] = useState() + const [projects, setProjects] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const { isLoaded: isAuthLoaded, userId } = useAuth() + const { isLoaded: isUserLoaded, user } = useUser() + + // Use the hook to fetch all repos (with an empty ID to get all) + const { repoData: allRepos, isLoading: reposLoading, error: reposError } = useRepoData(''); + + const handleConnectGitHub = () => { + window.open('https://accounts.clerk.dev/user', '_blank'); + } + + useEffect(() => { + // Process repos data when it's loaded + if (!reposLoading && allRepos) { + // Transform GitHub repos to match ProjectData interface + const projectData: ProjectData[] = allRepos.map((repo: any) => ({ + id: repo.id.toString(), + name: repo.name, + full_name: repo.full_name, + // Create a deployment object that matches your existing structure + deployments: [ + { + applicationDeploymentRecordData: { + url: repo.html_url + }, + branch: repo.default_branch, + createdAt: repo.updated_at, + createdBy: { + name: repo.owner?.login || 'Unknown' + } + } + ] + })); + + setProjects(projectData); + setIsLoading(false); + } else if (!reposLoading && reposError) { + setError(reposError); + setIsLoading(false); + } + }, [allRepos, reposLoading, reposError]); + + return ( + + {isLoading ? ( + // Full width loading spinner in bento layout +
+
+
+ ) : error ? ( + // Full width error state in bento layout +
+
+
+ +
+
+

Error: {error}

+

+ Please connect your GitHub account to see your repositories. +

+ +
+ ) : projects.length === 0 ? ( + // Full width empty state in bento layout +
+
+
+ +
+
+

Deploy your first app

+

+ Once connected, you can import a repository from your account or start with one of our templates. +

+ +
+ ) : ( + // Custom grid that spans the entire bento layout +
+
+ {projects.map((project) => ( + + ))} +
+
+ )} + + {/* Wrap in try/catch to prevent breaking if there are issues */} + {(() => { + try { + return ( + + ); + } catch (error) { + console.error('Failed to render CheckBalanceIframe:', error); + return null; + } + })()} +
+ ) +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/purchase/BuyServices.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/purchase/BuyServices.tsx new file mode 100644 index 0000000..7a2d8dc --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/purchase/BuyServices.tsx @@ -0,0 +1,142 @@ +'use client' + +import { PageWrapper } from '@/components/foundation/page-wrapper' +import { Button } from '@workspace/ui/components/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@workspace/ui/components/card' +/** + * BuyPrepaidService component allows users to buy prepaid services. + * It checks if the user's balance is sufficient and redirects them to the home page if it is. + * + * @returns {JSX.Element} A JSX element that renders the buy prepaid service page. + */ +const BuyPrepaidService = () => { + return ( + +
+
+
+

+ Laconic +

+

+ Webapp Deployment Plans +

+

+ Choose the perfect deployment plan for your needs. Scale your + applications with confidence. +

+
+ +
+ {/* Basic Plan */} + + + Basic + + A simple deployment option for small projects + +
+ $5.00 + /month +
+
+ +

+ 1 monthly webapp deployment +

+
+ + + +
+ + {/* Standard Plan */} + +
+ Most popular +
+ + Standard + + Perfect for growing projects and businesses + +
+ $50.00 + /month +
+
+ +

+ 10 monthly webapp deployments +

+
+ + + +
+ + {/* Premium Plan */} + + + Premium + + For enterprises with high-volume needs + +
+ $500.00 + /month +
+
+ +

+ 100 monthly webapp deployments +

+
+ + + +
+
+
+
+
+ ) +} + +export default BuyPrepaidService diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/purchase/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/purchase/page.tsx new file mode 100644 index 0000000..98bdb78 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/purchase/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next' +import BuyPrepaidService from './BuyServices' + +export const metadata: Metadata = { + title: 'Buy Prepaid Service', + description: 'Buy prepaid service page description' +} + +const Page = () => { + return +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/store/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/store/page.tsx new file mode 100644 index 0000000..deefc07 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/store/page.tsx @@ -0,0 +1,19 @@ +import { PageWrapper } from '@/components/foundation' +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Store Page', + description: 'Store page description' +} + +const Page = () => { + return ( + +
+

Hello from store

+
+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/support/SupportPlaceholder.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/support/SupportPlaceholder.tsx new file mode 100644 index 0000000..e29e6f7 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/support/SupportPlaceholder.tsx @@ -0,0 +1,255 @@ +'use client' + +import type React from 'react' + +import { + Bell, + ChevronRight, + CreditCard, + Globe, + HelpCircle, + Lock, + Shield, + User +} from 'lucide-react' +import Link from 'next/link' +import { useState } from 'react' + +import { ComingSoonOverlay } from '@/components/foundation' +import { Button } from '@workspace/ui/components/button' +import { Separator } from '@workspace/ui/components/separator' +import { useRouter } from 'next/navigation' +/** + * Settings category item component + * Renders a single settings category with an icon and label + */ +interface SettingsCategoryProps { + /** The icon to display for this category */ + icon: React.ReactNode + /** The label text for this category */ + label: string + /** Whether this category is currently active */ + isActive?: boolean +} + +function SettingsCategory({ + icon, + label, + isActive = false +}: SettingsCategoryProps) { + return ( + + {icon} + {label} + {isActive && } + + ) +} + +/** + * Settings page component + * Displays a simple settings interface with categories and placeholder content + */ +export default function SettingsPage() { + const router = useRouter() + const [activeCategory] = useState('profile') + const [formState, setFormState] = useState({ + darkTheme: false, + autoDetectTimezone: true, + allowAnalytics: true + }) + + /** + * Handle checkbox changes + */ + const handleCheckboxChange = (e: React.ChangeEvent) => { + const { id, checked } = e.target + setFormState((prev) => ({ + ...prev, + [id]: checked + })) + } + + return ( +
+ router.back()} + /> + + {/* Header */} +
+
+
+ + + Laconic Deploy + +

Settings

+
+
+
+ +
+
+ {/* Sidebar */} + + + {/* Main content */} +
+
+

+ Profile Settings +

+

+ Manage your account information and preferences. +

+
+ + + +
+
+

Personal Information

+

+ Update your personal details and how we contact you. +

+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+
+ + + +
+
+

Preferences

+

+ Customize your experience with Laconic Deploy. +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+
+ +
+
+

+ © {new Date().getFullYear()} Laconic Deploy. All rights reserved. +

+
+
+
+ ) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/support/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/support/page.tsx new file mode 100644 index 0000000..bfb0692 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/support/page.tsx @@ -0,0 +1,10 @@ +import { PageWrapper } from '@/components/foundation' +import SupportPlaceholder from './SupportPlaceholder' + +export default function SupportPage() { + return ( + + + + ) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/wallet/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/wallet/page.tsx new file mode 100644 index 0000000..773e97f --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/wallet/page.tsx @@ -0,0 +1,19 @@ +import { PageWrapper } from '@/components/foundation' +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Wallet Page', + description: 'Wallet page description' +} + +const Page = () => { + return ( + +
+

Hello from wallet

+
+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/layout.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/layout.tsx new file mode 100644 index 0000000..8b38c2c --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/layout.tsx @@ -0,0 +1,11 @@ +import type React from 'react' + +interface LayoutProps { + children: React.ReactNode +} + +const Layout: React.FC = async ({ children }) => { + return
{children}
+} + +export default Layout diff --git a/apps/deploy-fe/src/app/actions/github.ts b/apps/deploy-fe/src/app/actions/github.ts new file mode 100644 index 0000000..ba43da0 --- /dev/null +++ b/apps/deploy-fe/src/app/actions/github.ts @@ -0,0 +1,36 @@ +// app/actions/github.ts +'use server' + +import { auth, currentUser } from '@clerk/nextjs/server' +import { Octokit } from '@octokit/rest' +import type { Organization } from '@octokit/webhooks-types' + +export async function getGitHubOrgs() { + const { userId } = await auth() + + if (!userId) { + throw new Error('Unauthorized') + } + + const user = await currentUser() + const githubAccount = user?.externalAccounts.find( + (account) => account.provider === 'github' + ) + + const token = + githubAccount?.provider === 'github' ? githubAccount.externalId : null + + if (!token) { + throw new Error('GitHub not connected') + } + + const octokit = new Octokit({ auth: token }) + const { data } = await octokit.rest.orgs.listForAuthenticatedUser() + + return data.map((org: Organization) => ({ + id: org.id, + name: org.login, + login: org.login, + avatarUrl: org.avatar_url + })) +} diff --git a/apps/deploy-fe/src/app/api/auth/route.ts b/apps/deploy-fe/src/app/api/auth/route.ts new file mode 100644 index 0000000..dc56365 --- /dev/null +++ b/apps/deploy-fe/src/app/api/auth/route.ts @@ -0,0 +1,52 @@ +// nextjs route handler, checks import { NextResponse } from 'next/server' +import { auth } from '@clerk/nextjs/server' +import { clerkClient } from '@clerk/nextjs/server' +import { NextResponse } from 'next/server' + +export async function GET() { + await auth.protect() + + const clerk = await clerkClient() + const authenticated = await auth() + + // if (!userId) { + // return new Response('Unauthorized', { status: 401 }) + // } + + // if (!userId) { + // return new Response('Unauthorized', { status: 401 }) + // } + + const userId = authenticated.userId + + if (!userId) { + return new Response(JSON.stringify({ error: 'Error: No signed in user' }), { + status: 401 + }) + } + + console.log('Frome the api') + // try { + // let res + // if (userId) { + const res = await clerk.users.getUserOauthAccessToken(userId, 'github') + // } + // } catch (error) { + // console.error(error) + // } + // const github = await clerk.getUserOAuthAccessToken('oauth_github') + + // const { userId } = await auth() + // if (!userId) { + // return new Response('Unauthorized', { status: 401 }) + // } + + // const token = await getToken({ template: 'oauth_github' }) + + // Fetch data from Supabase and return it. + // const data = { token: token } + // console.log(data) + // + const data = res.data[0]?.token + return NextResponse.json({ res: data }) +} diff --git a/apps/deploy-fe/src/app/api/github/webhook/route.ts b/apps/deploy-fe/src/app/api/github/webhook/route.ts new file mode 100644 index 0000000..94620fb --- /dev/null +++ b/apps/deploy-fe/src/app/api/github/webhook/route.ts @@ -0,0 +1,66 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' +import type { WebhookEventName } from '@octokit/webhooks-types' + +// Add GET handler for GitHub webhook verification + +export async function GET() { + return new Response('Ready to receive webhooks', { status: 200 }) +} + +export async function POST(request: Request) { + try { + const payload = await request.text() + const signature256 = request.headers.get('x-hub-signature-256') + const signature1 = request.headers.get('x-hub-signature') + const eventType = request.headers.get('x-github-event') + const event = eventType as WebhookEventName + + console.log('Received webhook:', { + event, + signatures: { signature256, signature1 } + }) + + // Always use SHA256 if available + if (!signature256) { + return new Response('SHA256 signature required', { status: 401 }) + } + + const secret = process.env.GITHUB_WEBHOOK_SECRET + if (!secret) { + return new Response('Webhook secret not configured', { status: 500 }) + } + + // Calculate expected SHA256 hash + const expectedHash = createHmac('sha256', secret) + .update(payload) + .digest('hex') + + const providedHash = signature256.replace('sha256=', '') + + console.log('Verifying signature:', { + provided: providedHash, + expected: expectedHash, + match: providedHash === expectedHash + }) + + const signatureMatches = timingSafeEqual( + Buffer.from(providedHash), + Buffer.from(expectedHash) + ) + + if (!signatureMatches) { + return new Response('Invalid signature', { status: 401 }) + } + + const data = JSON.parse(payload) + console.log('Processing webhook:', { event, data }) + + return new Response('OK', { status: 200 }) + } catch (error: unknown) { + console.error('Webhook error:', error) + return new Response( + error instanceof Error ? error.message : 'Unknown error', + { status: 500 } + ) + } +} diff --git a/apps/deploy-fe/src/app/favicon.ico b/apps/deploy-fe/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ebae81e9a5987d13483269445c857818f9daeec6 GIT binary patch literal 15406 zcmeI3Y0Oo{8OJZ%%MLG#?8_TSg|f65UO*wC0+q@VHnlBH1EdIr3J6I-8$pCN!7r30 z!6<7>X~R+wL0P_tK!Ji~QNxxdWz|AiqyiOCoc@2~os+reo^zLXv1!7S{B!2adA6DJ z%rncmH<752sFWxyOz2vam{2*9=$=R;auSp;j0k-srz3G@I+B7RsKydSpW~bKGo9A`+_Mf zEHu-nPd9J8@rGHtbZLNP%a)nt%a@xmW5%E#USu4-q=Kneubw$^;)Io%Fkymu_uY5R z{rmS*fF5{-nMvWQx?ZFXyV#@*s#K|B=FgvR?%ur{=v>l}(VMscuak#w=FFMaXY6wCuz2xeCr_Uv3;j|s^sVEguc*UkSBGD{d-rzo(-BNk zfv)?A4Ppeiex|*XCQUMn7A^8+_lqAHpBn0BpWyVzUE$FPjU;r^7W#wn<8)A_j%Q)J zs!AjJ<&lDdf~Npw!Sz)db7?BbGeyGV0{N+oh&)3(YYX)S`U_B|j%V3&ky!GkDw1d* zbQZ=63xy4e^PPZlvW{o)z>7?2sge{BKSPC$!lwf7JQLvABn%Vi8+4R}$X|>@blnwP z%;kE$Cz&^eVgX(GPz^EvNcdRD)m|LmC(`j_0iC%Jxp$m!ONi5%Z#}xk3)O{8#8`Y< zNV83@ZqcHJ>CvNy#iNftY8)PW>@nk`XV0Dn^y<~iqE4MU_>0bGgt+m}xalCI^?BdE zea$Y-AD@5zx%u+TFSEksRd>Y~=>@!tjzZKJrEk^?j@MqNO`B%!+___O^`%Rf%z*<3 z>|E^6oz70&)w7SzKMPUsp^xf`*}#6ke*LU%+RYqZyLN5+Ok>`3@7~=UK72TsL*i^x zAF_{)eu7Wria!Z~ZRX|o-h0pLpD|-bQ1A0JXwbmy+_^KW4H z&70fzefjd`pxyp+S7si3@x>R-_3PJT{fMxCL-Nf8M?+Nb=QYFnf>=9v@L=GRcw);f zTedWF=gu`JPo7L!S4H$cIo zX2ggQcKsN^{~Og|8>|vi+E0AZe&U=lujge6Y^@fo{mPXqZw~8s_UXT)M~|9@4I5^) zPusPwFw&QmS3Fxh`^*z5GPIfYyK%K+$BvXWrq5raMh)}A3oqDrfBW`rbMD+Z!+NlB zQIf#}DG%*FhQE_~!idufz^<8J0=f`kk(h*fXT?k=y{jAiNf!5T?B(+tZqz%Sin2J>q%s+AVX4HftDBtd z>+#D&hYk(wGscK>|G6s@uf@g10dG?GA9Tf56g5@ldBLt*oSyJ?*sx(KI$0O3UAxxC z0`HqXr`-{@FG#mP??n0VJyqUh&E(ck;Vb6>_!tIp&b-AKjo4SS{^}#-LL z!a8g}CFDboi<$l^tqeZ{i1t?ROd;c)6vnQ@8 zaCQPHQ^zxS;4SMImdd=Tk|gk{uE1WUNcgVMTX<6IfT2L;A0+E|RwTfS46^9>riuQe zPw}Ij&|hPfGv!Uf5#jH`dF|~#1}dLR*70nUcv%OcHhq zH-!6wpYJl2;Y9{nbo3I?iEeC^2eGf^{Eaov9O0mFTfk43uk*bki;hFWTmjwK@b}QV zEfvup#AyrRCjz#Zw@c%9xSi-i=lfbi|5R6OVY9UF0(ssk>5M+VDwL()QSG=U8@vl_ zmL{I?ot*a_VVOX@hV7Nt{l8_4xtn~24<&gIyuZ%Eo5CHTynKz*dsjB!66imC$%mL@ z+6qgBjJ~tyt5vI(sb9ao1!rIRf-?*sb?er3>vY<%Jn0KQgMHfH$N$3A50 z)Tw6W%9ZAwcisuGZQC{z#`f*oO&FXn0M7b+%$qmQB+qtvH^kB_LPH^!If8h5TDT%a zzT0NanwbR)7ML?<&KS;BqW0tAy;(l@w261<*=J1uP{6lLMEg1j2ZYEx)2>}R+t2sz z-LresFTVJ~+`M_yT)leLT)A?^eD>LAhPr(G4f96$k1xzO9feHm4c1z}5^f59`)!%C zSK`9?&OUnGx^;$c&w~aHG6M$=G|xW!toGKl zo;_>*q2KxT%eNQzJnD+fv17;5&Yi>Y626b|A6x$sekR~+miL=N-{jVvRGxT*h_=|FK6-FBbe<%G|&@mUV5KPn-*F-n`lNA9-f*;K6A;QF7rr z@-DyYICA8O%~yQ?iW=it{Kv;>fZwZ!^+dFHV{X;id{U_I5ym-+J88Rem{*=D*Xxmrv^{n0<`39<>wMIA9T~ImF z9`WP)AO9J1r%#`b`hE~8@9QNW@okix#y28j$bX(oSvoFieb+<0b{^SyfS;W@bux42%(36MnGg8xzIX3lyM}Gswr%kJC0iTh>&Hp|#j44yprDiL zh@Z&5p#P)%=RBXaGtc8>sb@VuWy%!0o^Wd?zc0|UfB$})ld_CkYe&AGmCx*J92KdM zar{R4j}P?Y=+UEN{iomg4IJ}^?{m0JOfs%=m|Ga5CxteGBj)3MLZlz8#aVZTZMu8r z)uv6GM#>U1`K;sk?S%V|7Uz%Z|0=M@a>QJ=T8PX)+59J`y5A2|o96 z+MDUh{$bp>ahb*fKK@bg&xjbuG_d+jx&?*~CY z$S=Q)kG~MYYiQK^6!an|N9{}$&u_6e7;=DtX1N^#Yr#gw{Ht!VokkC zNQk8UJ ze)oOLR%R^m+t+HWF#1 zaEJb*4fvJ`{1+qJ`itkxP2_iUl4oN4#&^z+7YO)L5^5wN_L>>bp!Gbmze`z<&2@sm zPt8%w&Dhp%Cs*urLMeU}PLyuarK#!<@Lp!=}x(%hpP8`vso%t$nn7(ac5 zy#l$oEZc!DbfOy@`HoeIZ`$f5&X=tTE7LCn+48v62q;sRdnn literal 0 HcmV?d00001 diff --git a/apps/deploy-fe/src/app/layout.tsx b/apps/deploy-fe/src/app/layout.tsx new file mode 100644 index 0000000..9f557fd --- /dev/null +++ b/apps/deploy-fe/src/app/layout.tsx @@ -0,0 +1,38 @@ +import { Providers } from '@/components/providers' +import { ClerkProvider } from '@clerk/nextjs' +import '@workspace/ui/globals.css' +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import { CheckBalanceWrapper } from '@/components/iframe/check-balance-iframe/CheckBalanceWrapper' + +// Add root metadata with template pattern +export const metadata: Metadata = { + title: { + template: '%s | Laconic Deploy', + default: 'Laconic Deploy - Cloud Deployment Platform' + }, + description: 'Deploy your applications with Laconic', + metadataBase: new URL('https://deploy-laconic.qwrk.app'), + icons: { + icon: '/favicon.ico' + } +} + +const inter = Inter({ subsets: ['latin'] }) + +export default function RootLayout({ + children +}: Readonly<{ children: React.ReactNode }>) { + return ( + + + +
+ {children} + +
+ + +
+ ) +} diff --git a/apps/deploy-fe/src/app/loading.tsx b/apps/deploy-fe/src/app/loading.tsx new file mode 100644 index 0000000..6b9794c --- /dev/null +++ b/apps/deploy-fe/src/app/loading.tsx @@ -0,0 +1,5 @@ +import { LoadingOverlay } from '@/components/loading/loading-overlay' + +export default function Loading() { + return +} diff --git a/apps/deploy-fe/src/app/page.tsx b/apps/deploy-fe/src/app/page.tsx new file mode 100644 index 0000000..8ebba71 --- /dev/null +++ b/apps/deploy-fe/src/app/page.tsx @@ -0,0 +1,87 @@ +'use client' +import { useEffect, useState } from 'react' +import { PageWrapper } from '@/components/foundation' +import { Button } from '@workspace/ui/components/button' +import { Shapes } from 'lucide-react' +import Link from 'next/link' +import { useAuth, useUser } from '@clerk/nextjs' +import { LoadingOverlay } from '@/components/foundation/loading/loading-overlay' + +export default function HomePage() { + const [isLoading, setIsLoading] = useState(true) + const { isLoaded: isAuthLoaded, userId } = useAuth() + const { isLoaded: isUserLoaded } = useUser() + + useEffect(() => { + // Check authentication status + if (isAuthLoaded && isUserLoaded) { + setIsLoading(false) + } + }, [isAuthLoaded, isUserLoaded]) + + const handleConnectWallet = () => { + // Handle wallet connection + console.log('Connect wallet clicked') + } + + if (isLoading) { + return + } + + return ( + +
+ {userId ? ( + // User is authenticated +
+
+ +
+

Welcome to Laconic Deploy

+

+ Deploy your applications quickly and securely on the decentralized web. +

+ +
+ + +
+
+ ) : ( + // User is not authenticated +
+
+ +
+

Decentralized Web Deployment

+

+ Deploy your applications securely and efficiently with Laconic's web3 deployment platform. + Connect your wallet to get started. +

+ + + +

+ Already have an account? Sign in +

+
+ )} +
+ + {/* Removed the CheckBalanceIframe component that was causing errors */} +
+ ) +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/sign-in/[[...sign-in]]/page.tsx b/apps/deploy-fe/src/app/sign-in/[[...sign-in]]/page.tsx new file mode 100644 index 0000000..e04d5a6 --- /dev/null +++ b/apps/deploy-fe/src/app/sign-in/[[...sign-in]]/page.tsx @@ -0,0 +1,30 @@ +'use client' +import { PageWrapper } from '@/components/foundation' +import { SignIn } from '@clerk/nextjs' +import { dark } from '@clerk/themes' + +const Page = () => { + return ( + +
+ +
+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/components/assets/laconic-mark.tsx b/apps/deploy-fe/src/components/assets/laconic-mark.tsx new file mode 100644 index 0000000..faa858e --- /dev/null +++ b/apps/deploy-fe/src/components/assets/laconic-mark.tsx @@ -0,0 +1,43 @@ +/** + * The Laconic logo mark component. + * + * @component + * @example + * // Basic usage + * + * + * // With custom color + * + * + * // With custom size + * + */ +export function LaconicMark({ + /** + * Optional className for styling the SVG + * Use this to customize the size, color, and other SVG properties + */ + className = '' +}: { + className?: string +}) { + return ( + + Laconic logo + + + ) +} diff --git a/apps/deploy-fe/src/components/core/dropdown/Dropdown.tsx b/apps/deploy-fe/src/components/core/dropdown/Dropdown.tsx new file mode 100644 index 0000000..eb10dfb --- /dev/null +++ b/apps/deploy-fe/src/components/core/dropdown/Dropdown.tsx @@ -0,0 +1,54 @@ +import type React from 'react' +import type { DropdownProps } from './types' + +/** + * A simple dropdown component using the native select element. + * + * @component + * @param {DropdownProps} props - The props for the Dropdown component. + * @returns {React.ReactElement} A dropdown element. + * + * @example + * ```tsx + * console.log(option)} + * placeholder="Select an option" + * /> + * ``` + */ +export const Dropdown = ({ + placeholder, + options, + onChange, + value +}: DropdownProps) => { + const handleChange = (event: React.ChangeEvent) => { + const selectedOption = options.find( + (option) => option.value === event.target.value + ) + if (selectedOption) { + onChange(selectedOption) + } + } + + return ( + + ) +} diff --git a/apps/deploy-fe/src/components/core/dropdown/README.md b/apps/deploy-fe/src/components/core/dropdown/README.md new file mode 100644 index 0000000..d1f9315 --- /dev/null +++ b/apps/deploy-fe/src/components/core/dropdown/README.md @@ -0,0 +1,12 @@ +# Dropdown Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { Dropdown } from '@/components/dropdown'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/core/dropdown/index.ts b/apps/deploy-fe/src/components/core/dropdown/index.ts new file mode 100644 index 0000000..4d5b33b --- /dev/null +++ b/apps/deploy-fe/src/components/core/dropdown/index.ts @@ -0,0 +1,2 @@ +export * from './Dropdown' +export * from './types' diff --git a/apps/deploy-fe/src/components/core/dropdown/types.ts b/apps/deploy-fe/src/components/core/dropdown/types.ts new file mode 100644 index 0000000..586c7d7 --- /dev/null +++ b/apps/deploy-fe/src/components/core/dropdown/types.ts @@ -0,0 +1,11 @@ +export interface Option { + value: string + label: string +} + +export interface DropdownProps { + options: Option[] + onChange: (option: Option) => void + placeholder?: string + value?: Option +} diff --git a/apps/deploy-fe/src/components/core/format-milli-second/FormatMilliSecond.tsx b/apps/deploy-fe/src/components/core/format-milli-second/FormatMilliSecond.tsx new file mode 100644 index 0000000..7c70883 --- /dev/null +++ b/apps/deploy-fe/src/components/core/format-milli-second/FormatMilliSecond.tsx @@ -0,0 +1,31 @@ +import { intervalToDuration } from 'date-fns' +import React from 'react' +import type { FormatMilliSecondProps } from './types' + +/** + * A component that formats a given time in milliseconds into a human-readable format. + * + * @component + * @param {FormatMilliSecondProps} props - The props for the FormatMilliSecond component. + * @returns {React.ReactElement} A formatted time element. + * + * @example + * ```tsx + * + * ``` + */ +export const FormatMilliSecond = ({ + time, + ...props +}: FormatMilliSecondProps) => { + const duration = intervalToDuration({ start: 0, end: time }) + + return ( +
+ {duration.days !== 0 && {duration.days}d } + {duration.hours !== 0 && {duration.hours}h } + {duration.minutes !== 0 && {duration.minutes}m } + {duration.seconds}s +
+ ) +} diff --git a/apps/deploy-fe/src/components/core/format-milli-second/README.md b/apps/deploy-fe/src/components/core/format-milli-second/README.md new file mode 100644 index 0000000..42e8303 --- /dev/null +++ b/apps/deploy-fe/src/components/core/format-milli-second/README.md @@ -0,0 +1,12 @@ +# FormatMilliSecond Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { FormatMilliSecond } from '@/components/formatmillisecond'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/core/format-milli-second/index.ts b/apps/deploy-fe/src/components/core/format-milli-second/index.ts new file mode 100644 index 0000000..b8295b5 --- /dev/null +++ b/apps/deploy-fe/src/components/core/format-milli-second/index.ts @@ -0,0 +1,2 @@ +export * from './FormatMilliSecond' +export * from './types' diff --git a/apps/deploy-fe/src/components/core/format-milli-second/types.ts b/apps/deploy-fe/src/components/core/format-milli-second/types.ts new file mode 100644 index 0000000..f67de78 --- /dev/null +++ b/apps/deploy-fe/src/components/core/format-milli-second/types.ts @@ -0,0 +1,11 @@ +import type { ComponentPropsWithoutRef } from 'react' + +/** + * Props for the FormatMillisecond component. + * @interface FormatMilliSecondProps + * @property {number} time - The time in milliseconds to format. + */ +export interface FormatMilliSecondProps + extends ComponentPropsWithoutRef<'div'> { + time: number +} diff --git a/apps/deploy-fe/src/components/core/logo/Logo.tsx b/apps/deploy-fe/src/components/core/logo/Logo.tsx new file mode 100644 index 0000000..dffe8b0 --- /dev/null +++ b/apps/deploy-fe/src/components/core/logo/Logo.tsx @@ -0,0 +1,26 @@ +import Link from 'next/link' +import React from 'react' +import type { LogoProps } from './types' + +/** + * A component that renders the Laconic logo with a link to the organization's page. + * + * @component + * @param {LogoProps} props - The props for the Logo component. + * @returns {React.ReactElement} A logo element. + * + * @example + * ```tsx + * + * ``` + */ +export const Logo = ({ orgSlug }: LogoProps) => { + return ( + + Laconic Logo + + ) +} diff --git a/apps/deploy-fe/src/components/core/logo/README.md b/apps/deploy-fe/src/components/core/logo/README.md new file mode 100644 index 0000000..08bebbd --- /dev/null +++ b/apps/deploy-fe/src/components/core/logo/README.md @@ -0,0 +1,12 @@ +# Logo Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { Logo } from '@/components/logo'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/core/logo/index.ts b/apps/deploy-fe/src/components/core/logo/index.ts new file mode 100644 index 0000000..3371ed8 --- /dev/null +++ b/apps/deploy-fe/src/components/core/logo/index.ts @@ -0,0 +1,2 @@ +export * from './Logo' +export * from './types' diff --git a/apps/deploy-fe/src/components/core/logo/types.ts b/apps/deploy-fe/src/components/core/logo/types.ts new file mode 100644 index 0000000..fe6d492 --- /dev/null +++ b/apps/deploy-fe/src/components/core/logo/types.ts @@ -0,0 +1,8 @@ +/** + * Props for the Logo component. + * @interface LogoProps + * @property {string} [orgSlug] - The organization slug used for the link. + */ +export interface LogoProps { + orgSlug?: string +} diff --git a/apps/deploy-fe/src/components/core/search-bar/README.md b/apps/deploy-fe/src/components/core/search-bar/README.md new file mode 100644 index 0000000..7363e46 --- /dev/null +++ b/apps/deploy-fe/src/components/core/search-bar/README.md @@ -0,0 +1,12 @@ +# SearchBar Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { SearchBar } from '@/components/searchbar'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/core/search-bar/SearchBar.tsx b/apps/deploy-fe/src/components/core/search-bar/SearchBar.tsx new file mode 100644 index 0000000..bb43ed7 --- /dev/null +++ b/apps/deploy-fe/src/components/core/search-bar/SearchBar.tsx @@ -0,0 +1,51 @@ +import React, { forwardRef } from 'react' +import type { SearchBarProps } from './types' + +/** + * A search bar component with an icon input. + * + * @component + * @param {SearchBarProps} props - The props for the SearchBar component. + * @returns {React.ReactElement} A search bar element. + * + * @example + * ```tsx + * console.log(e.target.value)} /> + * ``` + */ +export const SearchBar = forwardRef( + ({ value, onChange, placeholder = 'Search', ...props }, ref) => { + return ( +
+
+ {/* Search icon SVG */} + +
+ +
+ ) + } +) diff --git a/apps/deploy-fe/src/components/core/search-bar/index.ts b/apps/deploy-fe/src/components/core/search-bar/index.ts new file mode 100644 index 0000000..2434dcf --- /dev/null +++ b/apps/deploy-fe/src/components/core/search-bar/index.ts @@ -0,0 +1,2 @@ +export * from './SearchBar' +export * from './types' diff --git a/apps/deploy-fe/src/components/core/search-bar/types.ts b/apps/deploy-fe/src/components/core/search-bar/types.ts new file mode 100644 index 0000000..fec58e6 --- /dev/null +++ b/apps/deploy-fe/src/components/core/search-bar/types.ts @@ -0,0 +1,4 @@ +export interface SearchBarProps + extends React.InputHTMLAttributes { + placeholder?: string +} diff --git a/apps/deploy-fe/src/components/core/stepper/README.md b/apps/deploy-fe/src/components/core/stepper/README.md new file mode 100644 index 0000000..b3dbb62 --- /dev/null +++ b/apps/deploy-fe/src/components/core/stepper/README.md @@ -0,0 +1,12 @@ +# Stepper Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { Stepper } from '@/components/stepper'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/core/stepper/Stepper.tsx b/apps/deploy-fe/src/components/core/stepper/Stepper.tsx new file mode 100644 index 0000000..d240c36 --- /dev/null +++ b/apps/deploy-fe/src/components/core/stepper/Stepper.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { StepperNav } from '../vertical-stepper/VerticalStepper' +import type { StepperProps, StepperValue } from './types' + +const COLOR_COMPLETED = '#059669' +const COLOR_ACTIVE = '#CFE6FC' +const COLOR_NOT_STARTED = '#F1F5F9' + +/** + * A stepper component that displays a series of steps with different states. + * + * @component + * @param {StepperProps} props - The props for the Stepper component. + * @returns {React.ReactElement} A stepper element. + * + * @example + * ```tsx + * + * ``` + */ +export const Stepper = ({ activeStep, stepperValues }: StepperProps) => { + return ( + { + return { + stepContent: () => ( +
+ {stepperValue.label} +
+ ), + stepStatusCircleSize: 30, + stepStateColor: + activeStep > stepperValue.step + ? COLOR_COMPLETED + : activeStep === stepperValue.step + ? COLOR_ACTIVE + : COLOR_NOT_STARTED + } + })} + /> + ) +} diff --git a/apps/deploy-fe/src/components/core/stepper/index.ts b/apps/deploy-fe/src/components/core/stepper/index.ts new file mode 100644 index 0000000..faa47e8 --- /dev/null +++ b/apps/deploy-fe/src/components/core/stepper/index.ts @@ -0,0 +1,2 @@ +export * from './Stepper' +export * from './types' diff --git a/apps/deploy-fe/src/components/core/stepper/types.ts b/apps/deploy-fe/src/components/core/stepper/types.ts new file mode 100644 index 0000000..e2b7364 --- /dev/null +++ b/apps/deploy-fe/src/components/core/stepper/types.ts @@ -0,0 +1,23 @@ +/** + * Represents a step in the stepper. + * @interface StepperValue + * @property {number} step - The step number. + * @property {string} route - The route associated with the step. + * @property {string} label - The label for the step. + */ +export interface StepperValue { + step: number + route: string + label: string +} + +/** + * Props for the Stepper component. + * @interface StepperProps + * @property {number} activeStep - The currently active step. + * @property {StepperValue[]} stepperValues - The values for each step. + */ +export interface StepperProps { + activeStep: number + stepperValues: StepperValue[] +} diff --git a/apps/deploy-fe/src/components/core/stop-watch/README.md b/apps/deploy-fe/src/components/core/stop-watch/README.md new file mode 100644 index 0000000..7ab0a90 --- /dev/null +++ b/apps/deploy-fe/src/components/core/stop-watch/README.md @@ -0,0 +1,12 @@ +# StopWatch Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { StopWatch } from '@/components/stopwatch'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/core/stop-watch/StopWatch.tsx b/apps/deploy-fe/src/components/core/stop-watch/StopWatch.tsx new file mode 100644 index 0000000..5bb576a --- /dev/null +++ b/apps/deploy-fe/src/components/core/stop-watch/StopWatch.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useRef, useState } from 'react' +import { FormatMilliSecond } from '../format-milli-second' +import type { StopwatchProps } from './types' + +export const setStopWatchOffset = (time: string) => { + const providedTime = new Date(time) + const currentTime = new Date() + const timeDifference = currentTime.getTime() - providedTime.getTime() + currentTime.setMilliseconds(currentTime.getMilliseconds() + timeDifference) + return currentTime +} + +/** + * A stopwatch component that tracks elapsed time. + * + * @component + * @param {StopwatchProps} props - The props for the Stopwatch component. + * @returns {React.ReactElement} A stopwatch element. + * + * @example + * ```tsx + * + * ``` + */ +export const StopWatch = ({ + offsetTimestamp, + isPaused, + ...props +}: StopwatchProps) => { + const [elapsedTime, setElapsedTime] = useState(0) + const intervalRef = useRef(null) + const startTimeRef = useRef(offsetTimestamp.getTime()) + + // Set start time when offsetTimestamp changes + useEffect(() => { + startTimeRef.current = offsetTimestamp.getTime() + }, [offsetTimestamp]) + + // Handle timer start/stop based on isPaused state + useEffect(() => { + // Clear any existing interval + if (intervalRef.current !== null) { + window.clearInterval(intervalRef.current) + intervalRef.current = null + } + + if (!isPaused) { + // Start the timer + intervalRef.current = window.setInterval(() => { + const now = Date.now() + const elapsed = now - startTimeRef.current + setElapsedTime(elapsed) + }, 1000) // Update every second + } + + // Cleanup on unmount + return () => { + if (intervalRef.current !== null) { + window.clearInterval(intervalRef.current) + } + } + }, [isPaused]) // Only re-run when isPaused changes + + return +} diff --git a/apps/deploy-fe/src/components/core/stop-watch/index.ts b/apps/deploy-fe/src/components/core/stop-watch/index.ts new file mode 100644 index 0000000..c18a27c --- /dev/null +++ b/apps/deploy-fe/src/components/core/stop-watch/index.ts @@ -0,0 +1,2 @@ +export * from './StopWatch' +export * from './types' diff --git a/apps/deploy-fe/src/components/core/stop-watch/types.ts b/apps/deploy-fe/src/components/core/stop-watch/types.ts new file mode 100644 index 0000000..8b1b2c7 --- /dev/null +++ b/apps/deploy-fe/src/components/core/stop-watch/types.ts @@ -0,0 +1,12 @@ +import type { FormatMilliSecondProps } from '../format-milli-second' + +/** + * Props for the Stopwatch component. + * @interface StopwatchProps + * @property {Date} offsetTimestamp - The initial timestamp for the stopwatch. + * @property {boolean} isPaused - Whether the stopwatch is paused. + */ +export interface StopwatchProps extends Omit { + offsetTimestamp: Date + isPaused: boolean +} diff --git a/apps/deploy-fe/src/components/core/vertical-stepper/README.md b/apps/deploy-fe/src/components/core/vertical-stepper/README.md new file mode 100644 index 0000000..ff9de29 --- /dev/null +++ b/apps/deploy-fe/src/components/core/vertical-stepper/README.md @@ -0,0 +1,12 @@ +# VerticalStepper Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { VerticalStepper } from '@/components/verticalstepper'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/core/vertical-stepper/VerticalStepper.tsx b/apps/deploy-fe/src/components/core/vertical-stepper/VerticalStepper.tsx new file mode 100644 index 0000000..a37fa69 --- /dev/null +++ b/apps/deploy-fe/src/components/core/vertical-stepper/VerticalStepper.tsx @@ -0,0 +1,103 @@ +import type React from 'react' +import type { ISeparator, IStep, IStepperNavProps } from './types' + +/** + * A navigation component for displaying steps in a vertical layout. + * + * @component + * @param {IStepperNavProps} props - The props for the StepperNav component. + * @returns {React.ReactElement} A stepper navigation element. + * + * @example + * ```tsx + *
Step 1
}]} /> + * ``` + */ +export const StepperNav = (props: IStepperNavProps): JSX.Element => { + return ( + + ) +} + +/** + * A separator component for the vertical stepper. + * + * @component + * @param {ISeparator} props - The props for the Separator component. + * @returns {React.ReactElement} A separator element. + */ +export const Separator = ({ height }: ISeparator): JSX.Element => { + return ( +
+ ) +} + +/** + * A step component for the vertical stepper. + * + * @component + * @param {IStep} props - The props for the Step component. + * @returns {React.ReactElement} A step element. + */ +export const Step = ({ + stepContent, + statusColor, + statusCircleSize, + onClickHandler +}: IStep): JSX.Element => { + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + onClickHandler?.() + } + } + + return ( + + ) +} diff --git a/apps/deploy-fe/src/components/core/vertical-stepper/index.ts b/apps/deploy-fe/src/components/core/vertical-stepper/index.ts new file mode 100644 index 0000000..9f0ca5c --- /dev/null +++ b/apps/deploy-fe/src/components/core/vertical-stepper/index.ts @@ -0,0 +1,2 @@ +export * from './types' +export * from './VerticalStepper' diff --git a/apps/deploy-fe/src/components/core/vertical-stepper/types.ts b/apps/deploy-fe/src/components/core/vertical-stepper/types.ts new file mode 100644 index 0000000..f8490ca --- /dev/null +++ b/apps/deploy-fe/src/components/core/vertical-stepper/types.ts @@ -0,0 +1,47 @@ +/** + * Describes a step in the stepper navigation. + * @interface IStepDescription + * @property {() => JSX.Element} stepContent - The content of the step. + * @property {string} [stepStateColor] - The color representing the step's state. + * @property {number} [stepStatusCircleSize] - The size of the status circle. + * @property {() => void} [onClickHandler] - Handler for click events on the step. + */ +export interface IStepDescription { + stepContent: () => JSX.Element + stepStateColor?: string + stepStatusCircleSize?: number + onClickHandler?: () => void +} + +/** + * Props for the StepperNav component. + * @interface IStepperNavProps + * @property {IStepDescription[]} steps - The steps to display in the navigation. + */ +export interface IStepperNavProps { + steps: IStepDescription[] +} + +/** + * Props for the Separator component. + * @interface ISeparator + * @property {string | number} [height] - The height of the separator. + */ +export interface ISeparator { + height?: string | number +} + +/** + * Props for the Step component. + * @interface IStep + * @property {() => JSX.Element} stepContent - The content of the step. + * @property {string} [statusColor] - The color of the status circle. + * @property {number} [statusCircleSize] - The size of the status circle. + * @property {() => void} [onClickHandler] - Handler for click events on the step. + */ +export interface IStep { + stepContent: () => JSX.Element + statusColor?: string + statusCircleSize?: number + onClickHandler?: () => void +} diff --git a/apps/deploy-fe/src/components/foundation/coming-soon-overlay/ComingSoonOverlay.tsx b/apps/deploy-fe/src/components/foundation/coming-soon-overlay/ComingSoonOverlay.tsx new file mode 100644 index 0000000..b8545ee --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/coming-soon-overlay/ComingSoonOverlay.tsx @@ -0,0 +1,32 @@ +import { Button } from '@workspace/ui/components/button' +import { Globe } from 'lucide-react' + +/** + * Coming Soon overlay component + * Displays a message indicating a feature is not yet available + * + * @param props.message - Optional custom message to display + * @param props.routerAction - Optional router action to perform when the button is clicked + */ +interface ComingSoonOverlayProps { + message?: string + routerAction?: () => void + buttonText?: string +} + +export function ComingSoonOverlay({ + message = 'This feature will be available in the next release.', + routerAction, + buttonText = 'Get Notified' +}: ComingSoonOverlayProps) { + return ( +
+
+ +

Coming Soon

+

{message}

+ {routerAction && } +
+
+ ) +} diff --git a/apps/deploy-fe/src/components/foundation/coming-soon-overlay/index.ts b/apps/deploy-fe/src/components/foundation/coming-soon-overlay/index.ts new file mode 100644 index 0000000..c6c0302 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/coming-soon-overlay/index.ts @@ -0,0 +1 @@ +export * from './ComingSoonOverlay' diff --git a/apps/deploy-fe/src/components/foundation/github-session-button/GitHubSessionButton.tsx b/apps/deploy-fe/src/components/foundation/github-session-button/GitHubSessionButton.tsx new file mode 100644 index 0000000..727e50e --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/github-session-button/GitHubSessionButton.tsx @@ -0,0 +1,9 @@ +import type { FC } from 'react' +import type { GitHubSessionButtonProps } from './types' + +/** + * GitHubSessionButton component + */ +export const GitHubSessionButton: FC = (props) => { + return
{/* Component implementation will be migrated here */}
+} diff --git a/apps/deploy-fe/src/components/foundation/github-session-button/README.md b/apps/deploy-fe/src/components/foundation/github-session-button/README.md new file mode 100644 index 0000000..29fdb70 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/github-session-button/README.md @@ -0,0 +1,12 @@ +# GitHubSessionButton Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { GitHubSessionButton } from '@/components/githubsessionbutton'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/foundation/github-session-button/index.ts b/apps/deploy-fe/src/components/foundation/github-session-button/index.ts new file mode 100644 index 0000000..0fb333b --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/github-session-button/index.ts @@ -0,0 +1,2 @@ +export * from './GitHubSessionButton' +export * from './types' diff --git a/apps/deploy-fe/src/components/foundation/github-session-button/types.ts b/apps/deploy-fe/src/components/foundation/github-session-button/types.ts new file mode 100644 index 0000000..ee34f16 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/github-session-button/types.ts @@ -0,0 +1,3 @@ +import type { Button } from '@workspace/ui/components/button' + +export type GitHubSessionButtonProps = typeof Button diff --git a/apps/deploy-fe/src/components/foundation/index.ts b/apps/deploy-fe/src/components/foundation/index.ts new file mode 100644 index 0000000..086b985 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/index.ts @@ -0,0 +1,17 @@ +// Exporting all components and types from the foundation directory + +// Page Header +export * from './page-header' + +// Page Wrapper +export * from './page-wrapper' + +// Navigation Wrapper +export * from './navigation-wrapper' + +// Top Navigation +export * from './coming-soon-overlay' +export * from './top-navigation/dark-mode-toggle' +export * from './top-navigation/main-navigation' +export * from './top-navigation/navigation-item' +export * from './top-navigation/wallet-session-badge' diff --git a/apps/deploy-fe/src/components/foundation/laconic-icon/LaconicIcon.tsx b/apps/deploy-fe/src/components/foundation/laconic-icon/LaconicIcon.tsx new file mode 100644 index 0000000..53d8b6f --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/laconic-icon/LaconicIcon.tsx @@ -0,0 +1,28 @@ +import type { FC } from 'react' +import type { LaconicIconProps } from './types' + +export const LaconicIcon: FC = ({ + className = '', + width = 40, + height = 40 +}) => { + return ( + + ) +} diff --git a/apps/deploy-fe/src/components/foundation/laconic-icon/README.md b/apps/deploy-fe/src/components/foundation/laconic-icon/README.md new file mode 100644 index 0000000..ed2056b --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/laconic-icon/README.md @@ -0,0 +1,12 @@ +# LaconicIcon Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { LaconicIcon } from '@/components/laconicicon'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/foundation/laconic-icon/index.ts b/apps/deploy-fe/src/components/foundation/laconic-icon/index.ts new file mode 100644 index 0000000..1014922 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/laconic-icon/index.ts @@ -0,0 +1,2 @@ +export * from './LaconicIcon' +export * from './types' diff --git a/apps/deploy-fe/src/components/foundation/laconic-icon/types.ts b/apps/deploy-fe/src/components/foundation/laconic-icon/types.ts new file mode 100644 index 0000000..84dfc99 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/laconic-icon/types.ts @@ -0,0 +1,19 @@ +/** + * LaconicIconProps interface defines the props for the LaconicIcon component. + */ +export interface LaconicIconProps { + /** + * Optional CSS class names to apply to the component. + */ + className?: string + /** + * The width of the icon. + * @default 40 + */ + width?: number + /** + * The height of the icon. + * @default 40 + */ + height?: number +} diff --git a/apps/deploy-fe/src/components/foundation/loading/loading-overlay/LoadingOverlay.tsx b/apps/deploy-fe/src/components/foundation/loading/loading-overlay/LoadingOverlay.tsx new file mode 100644 index 0000000..c8c2c8a --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/loading/loading-overlay/LoadingOverlay.tsx @@ -0,0 +1,83 @@ +'use client' + +import { LaconicMark } from '@/components/assets/laconic-mark' +import { cn } from '@workspace/ui/lib/utils' +import { Loader2 } from 'lucide-react' + +export interface LoadingOverlayProps { + /** + * Controls the visibility of the overlay. + * When false, the component returns null. + * @default true + */ + isLoading?: boolean + + /** + * Optional className for styling the overlay container. + * This will be merged with the default styles. + */ + className?: string + + /** + * Whether to show the Laconic logo in the overlay. + * @default true + */ + showLogo?: boolean + + /** + * Whether to show the loading spinner below the logo. + * @default true + */ + showSpinner?: boolean + + /** + * The z-index value for the overlay. + * Adjust this if you need the overlay to appear above or below other elements. + * @default 50 + */ + zIndex?: number + + /** + * Whether to use solid black background instead of semi-transparent. + * Useful for initial page load and full-screen loading states. + * @default false + */ + solid?: boolean +} + +export function LoadingOverlay({ + isLoading = true, + className, + showLogo = true, + showSpinner = true, + zIndex = 50, + solid = false +}: LoadingOverlayProps) { + if (!isLoading) return null + + return ( +
+ {showLogo && ( +
+ +
+ )} + {showSpinner && ( +
+ ) +} diff --git a/apps/deploy-fe/src/components/foundation/loading/loading-overlay/README.md b/apps/deploy-fe/src/components/foundation/loading/loading-overlay/README.md new file mode 100644 index 0000000..c052823 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/loading/loading-overlay/README.md @@ -0,0 +1,29 @@ +# LoadingOverlay Component + +A component to display a loading overlay with optional logo and spinner. + +## Features + +- Displays a loading overlay +- Optional Laconic logo and spinner +- Customizable styling and z-index + +## Usage + +```tsx +import { LoadingOverlay } from 'path/to/components/foundation/loading/loading-overlay' + +// Basic usage + +``` + +## Props + +| Prop | Type | Description | +|------|------|-------------| +| `isLoading` | `boolean` | Controls the visibility of the overlay | +| `className` | `string` | Optional className for styling | +| `showLogo` | `boolean` | Whether to show the Laconic logo | +| `showSpinner` | `boolean` | Whether to show the loading spinner | +| `zIndex` | `number` | The z-index value for the overlay | +| `solid` | `boolean` | Whether to use a solid black background | \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/loading/loading-overlay/index.ts b/apps/deploy-fe/src/components/foundation/loading/loading-overlay/index.ts new file mode 100644 index 0000000..77095f9 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/loading/loading-overlay/index.ts @@ -0,0 +1 @@ +export { LoadingOverlay } from './LoadingOverlay' diff --git a/apps/deploy-fe/src/components/foundation/navigation-wrapper/NavigationWrapper.tsx b/apps/deploy-fe/src/components/foundation/navigation-wrapper/NavigationWrapper.tsx new file mode 100644 index 0000000..5165e8e --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/navigation-wrapper/NavigationWrapper.tsx @@ -0,0 +1,225 @@ +'use client' + +import { cn } from '@workspace/ui/lib/utils' +import type { ReactNode } from 'react' + +/** + * Props for the NavigationWrapper component + * @remarks + * Configuration interface for NavigationWrapper, a layout component that provides: + * - Full-height, full-width container with flex column layout + * - Support for top navigation bar (currently commented out) + * - Flexible content area for page content + * - Customizable styling through className prop + * + * @see {@link NavigationWrapper} for the component implementation + */ +export interface NavigationWrapperProps { + /** + * Main content for the navigation wrapper + * @remarks + * - Typically contains {@link PageWrapper} components + * - Rendered in the main content area below navigation + * - Can include any valid React nodes + * - Takes up remaining vertical space + * + * @example + * ```tsx + * // Basic usage with PageWrapper + * + * + * + * + * + * + * // Multiple pages in tabs/routes + * + * {selectedTab === 'dashboard' && ( + * + * + * + * )} + * {selectedTab === 'settings' && ( + * + * + * + * )} + * + * ``` + */ + children: ReactNode + + /** + * Optional CSS classes for the wrapper + * @remarks + * - Applied to the wrapper's root container + * - Combined with default classes using the cn utility + * - Default classes: 'flex flex-col min-h-screen w-full' + * + * @example + * ```tsx + * // Custom background + * className="bg-background" + * + * // Custom max width + * className="max-w-7xl mx-auto" + * + * // Custom padding + * className="px-4 md:px-6" + * ``` + */ + className?: string +} + +/** + * A layout component that provides navigation structure and content organization. + * + * @description + * NavigationWrapper is a foundational layout component that: + * - Creates a full-height, full-width container + * - Supports top navigation (implementation commented out) + * - Provides flexible content area for page content + * - Typically wraps {@link PageWrapper} components + * + * @keywords layout, navigation, container, foundation-component + * @category Layout + * @scope Foundation + * + * @usage + * Common patterns: + * + * Basic app layout: + * ```tsx + * // In app/layout.tsx + * export default function RootLayout({ children }) { + * return ( + * + * + * + * {children} + * + * + * + * ) + * } + * ``` + * + * With custom styling: + * ```tsx + * + * + * + * + * + * ``` + * + * With route-based content: + * ```tsx + * + * + * + * + * + * } + * /> + * + * + * + * } + * /> + * + * + * ``` + * + * @example + * ```tsx + * // Basic usage + * + * + *
Page content
+ *
+ *
+ * + * // With custom styling + * + * + *
Styled content
+ *
+ *
+ * ``` + * + * @param props - Component props + * @param props.children - Main content to be rendered within the wrapper + * @param props.className - Additional CSS classes for the root container + * + * @returns A layout wrapper with navigation structure + * + * @related {@link PageWrapper} - Commonly wrapped by NavigationWrapper + * @composition Uses {@link cn} for class name merging + * + * @cssUtilities + * - flex-col: Column layout + * - min-h-screen: Minimum full viewport height + * - w-full: Full width + * + * @accessibility + * - Maintains semantic HTML structure + * - Preserves content hierarchy + * - Supports keyboard navigation (when nav is implemented) + * + * @performance + * - Minimal DOM nesting + * - Uses utility classes for styling + * - Conditional navigation rendering + */ +export default function NavigationWrapper({ + children, + className +}: NavigationWrapperProps) { + return ( +
+ {/* Top Navigation */} + {/*
+
+
+ + App Logo + +
+ +
+ +
+
+
*/} + + {/* Main Content */} + {children} +
+ ) +} diff --git a/apps/deploy-fe/src/components/foundation/navigation-wrapper/README.md b/apps/deploy-fe/src/components/foundation/navigation-wrapper/README.md new file mode 100644 index 0000000..1e5effd --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/navigation-wrapper/README.md @@ -0,0 +1,28 @@ +# NavigationWrapper Component + +A container component that wraps page content with navigation functionality. + +## Features + +- Wraps content with navigation +- Customizable styling + +## Usage + +```tsx +import { NavigationWrapper } from 'path/to/components/foundation/navigation-wrapper' + +// Basic usage + +
Content with navigation
+
+``` + +## Props + +See [types.ts](./types.ts) for detailed type definitions. + +| Prop | Type | Description | +|------|------|-------------| +| `children` | `ReactNode` | Content to be displayed within the navigation wrapper | +| `className` | `string` | Additional CSS classes | \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/navigation-wrapper/index.ts b/apps/deploy-fe/src/components/foundation/navigation-wrapper/index.ts new file mode 100644 index 0000000..f35672a --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/navigation-wrapper/index.ts @@ -0,0 +1,4 @@ +export { + default as NavigationWrapper, + type NavigationWrapperProps +} from './NavigationWrapper' diff --git a/apps/deploy-fe/src/components/foundation/page-header/PageHeader.tsx b/apps/deploy-fe/src/components/foundation/page-header/PageHeader.tsx new file mode 100644 index 0000000..7f8e998 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/page-header/PageHeader.tsx @@ -0,0 +1,343 @@ +'use client' + +import { Button } from '@workspace/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@workspace/ui/components/dropdown-menu' +import { cn } from '@workspace/ui/lib/utils' +import { MoreVertical } from 'lucide-react' +import Link from 'next/link' +import type { ReactNode } from 'react' + +/** + * Configuration for header action buttons/links + * @remarks + * Interactive elements with configurable styling and behavior: + * - Use onClick for JS actions OR href for navigation (not both) + * - Multiple visual styles via variant prop + * - Optional primary emphasis for main call-to-action + */ +export interface PageAction { + /** + * Display text for the action button/link + * @remarks Shown as the button/link text content + */ + label: string + + /** + * Visual style variant for the button + * @remarks + * Available styles: + * - `default`: Standard appearance + * - `destructive`: Dangerous actions + * - `outline`: Bordered, transparent bg + * - `secondary`: Less prominent + * - `ghost`: Minimal styling + * - `link`: Hyperlink style + * @default 'default' + */ + variant?: + | 'default' // Standard appearance + | 'destructive' // Dangerous actions + | 'outline' // Bordered, transparent bg + | 'secondary' // Less prominent + | 'ghost' // Minimal styling + | 'link' // Hyperlink style + + /** + * Click handler for button-based actions + * @remarks + * - Use for JavaScript-triggered actions + * - Mutually exclusive with `href` + */ + onClick?: () => void + + /** + * URL for link-based actions + * @remarks + * - Use for navigation to new URLs + * - Mutually exclusive with `onClick` + */ + href?: string + + /** + * Whether this action should have primary visual emphasis + * @remarks + * - When true, applies prominent styling + * - Useful for main call-to-action buttons + * - Affects mobile layout (primary actions shown, secondary in dropdown) + * @default false + */ + isPrimary?: boolean +} + +/** + * Props for the PageHeader component + * @remarks + * Configuration interface for PageHeader, providing: + * - Required title as main heading + * - Optional subtitle for additional context + * - Optional action buttons/links + * - Responsive layout with mobile optimization + * - Customizable styling + */ +export interface PageHeaderProps { + /** + * Main heading text + * @remarks + * - Rendered as h1 element + * - Responsive text size (2xl on mobile, 30px on desktop) + * - Bold weight with consistent line height + */ + title: string + + /** + * Additional content below the title + * @remarks + * - Can be plain text or custom component + * - Text is muted and slightly smaller + * - Components receive full width + * + * @example + * ```tsx + * // Text subtitle + * subtitle="Optional description" + * + * // Component subtitle + * subtitle={} + * ``` + */ + subtitle?: string | ReactNode + + /** + * Array of action buttons/links + * @remarks + * - Desktop: All actions shown in a row + * - Mobile: Primary actions shown, secondary in dropdown + * - Actions can be buttons (onClick) or links (href) + * - Support multiple visual styles via variant prop + * + * @see {@link PageAction} for detailed action configuration + * + * @example + * ```tsx + * actions={[ + * { + * label: "Create New", + * isPrimary: true, + * onClick: () => setOpen(true) + * }, + * { + * label: "View All", + * href: "/items", + * variant: "outline" + * } + * ]} + * ``` + */ + actions?: PageAction[] + + /** + * Optional CSS classes + * @remarks + * - Applied to the header's root container + * - Combined with default classes using cn utility + * - Default max-width of 1232px with auto margins + */ + className?: string +} + +/** + * A responsive page header component with title, subtitle, and actions. + * + * @description + * PageHeader provides a consistent header structure with: + * - Prominent title as h1 + * - Optional subtitle or custom component + * - Configurable action buttons/links + * - Responsive layout with mobile optimization + * - Customizable styling + * + * @keywords header, page-title, action-buttons, responsive-header, foundation-component + * @category Layout + * @scope Foundation + * + * @usage + * Common patterns: + * + * Basic title only: + * ```tsx + * + * ``` + * + * With subtitle and primary action: + * ```tsx + * + * ``` + * + * With search component and multiple actions: + * ```tsx + * } + * actions={[ + * { label: "Invite", isPrimary: true, onClick: handleInvite }, + * { label: "Export", variant: "outline", onClick: handleExport }, + * { label: "Settings", href: "/team/settings", variant: "ghost" } + * ]} + * /> + * ``` + * + * With navigation actions: + * ```tsx + * + * ``` + * + * @example + * ```tsx + * console.log("clicked") + * } + * ]} + * className="mb-8" + * /> + * ``` + * + * @related {@link PageWrapper} - Often used together for page layout + * @related {@link Button} - Used for rendering actions + * @composition Uses {@link DropdownMenu} for mobile action menu + * + * @cssUtilities + * - flex-col/flex-row: Responsive layout + * - gap-6/gap-2: Consistent spacing + * - text-2xl/text-[30px]: Responsive typography + * - text-foreground/text-muted-foreground: Text hierarchy + * + * @accessibility + * - Uses semantic h1 for title + * - Maintains text contrast ratios + * - Dropdown menu is keyboard navigable + * - Preserves action button/link semantics + * + * @performance + * - Conditional rendering of subtitle and actions + * - Mobile-first CSS with responsive modifiers + * - Efficient action rendering with key prop + */ +export default function PageHeader({ + title, + subtitle, + actions = [], + className +}: PageHeaderProps) { + // Separate primary actions from secondary actions + const primaryActions = actions.filter((action) => action.isPrimary) + const secondaryActions = actions.filter((action) => !action.isPrimary) + + // Render an action (either as button or link) + const renderAction = (action: PageAction, key: string) => { + const variant = action.variant || (action.isPrimary ? 'default' : 'outline') + + if (action.href) { + return ( + + ) + } + + return ( + + ) + } + + return ( +
+
+
+

+ {title} +

+ {subtitle && ( +
+ {typeof subtitle === 'string' ? ( +

+ {subtitle} +

+ ) : ( + subtitle + )} +
+ )} +
+ + {actions.length > 0 && ( + <> + {/* Desktop buttons */} +
+ {actions.map((action, index) => + renderAction(action, `desktop-${index}`) + )} +
+ + {/* Mobile buttons */} +
+ {primaryActions.map((action, index) => + renderAction(action, `mobile-primary-${index}`) + )} + + {secondaryActions.length > 0 && ( + + + + + + {secondaryActions.map((action) => + action.href ? ( + + {action.label} + + ) : ( + + {action.label} + + ) + )} + + + )} +
+ + )} +
+
+ ) +} diff --git a/apps/deploy-fe/src/components/foundation/page-header/README.md b/apps/deploy-fe/src/components/foundation/page-header/README.md new file mode 100644 index 0000000..066dbc0 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/page-header/README.md @@ -0,0 +1,62 @@ +# PageHeader Component + +A flexible page header component that displays a title with an optional subtitle and action buttons. + +## Features + +- Title with optional subtitle (text or component) +- Support for primary and secondary actions +- Responsive design with mobile-optimized action menu +- Customizable styling + +## Usage + +```tsx +import { PageHeader } from 'path/to/components/foundation/page-header' + +// Basic usage + + +// With actions + {} }, + { label: 'Secondary Action', href: '/some-path' } + ]} +/> + +// With component as subtitle +} + actions={[...]} +/> +``` + +## Props + +See [types.ts](./types.ts) for detailed type definitions. + +| Prop | Type | Description | +|------|------|-------------| +| `title` | `string` | The main heading text | +| `subtitle` | `string \| ReactNode` | Text or component to display below the title | +| `actions` | `PageAction[]` | Array of action buttons/links | +| `className` | `string` | Additional CSS classes | + +## Action Configuration + +Each action in the `actions` array can have the following properties: + +| Property | Type | Description | +|----------|------|-------------| +| `label` | `string` | Display text for the button/link | +| `variant` | `string` | Button style variant | +| `onClick` | `() => void` | Click handler (for button actions) | +| `href` | `string` | URL (for link actions) | +| `isPrimary` | `boolean` | Whether this is a primary action | \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/page-header/index.ts b/apps/deploy-fe/src/components/foundation/page-header/index.ts new file mode 100644 index 0000000..69d4bfd --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/page-header/index.ts @@ -0,0 +1,5 @@ +export { + default as PageHeader, + type PageAction, + type PageHeaderProps +} from './PageHeader' diff --git a/apps/deploy-fe/src/components/foundation/page-wrapper/PageWrapper.tsx b/apps/deploy-fe/src/components/foundation/page-wrapper/PageWrapper.tsx new file mode 100644 index 0000000..87bd4f7 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/page-wrapper/PageWrapper.tsx @@ -0,0 +1,273 @@ +import { cn } from '@workspace/ui/lib/utils' +import type { ReactNode } from 'react' +import { + type PageAction, + PageHeader, + type PageHeaderProps +} from '../page-header' + +/** + * Props for the PageWrapper component + * @remarks + * Configuration interface for PageWrapper, a layout component that provides: + * - Optional header with title, subtitle, and actions + * - Flexible content area with two layout modes + * - Responsive padding and spacing + * - Customizable styling + * + * @see {@link PageWrapper} for the component implementation + */ +export interface PageWrapperProps { + /** + * Header configuration for the page + * @remarks + * Configures the page header section with: + * - Required title: Main heading text + * - Optional subtitle: Text or custom component below title + * - Optional actions: Array of clickable/linkable buttons + * • label: Button text + * • variant: Visual style ('default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link') + * • onClick/href: Action handler (mutually exclusive) + * • isPrimary: Gives visual emphasis and affects mobile layout + * - Optional className: Custom styling for header + * + * @example + * ```tsx + * header={{ + * title: "Page Title", + * subtitle: "Optional description or component", + * actions: [ + * { + * label: "Primary Action", + * isPrimary: true, + * onClick: () => console.log("clicked") + * }, + * { + * label: "Secondary Action", + * href: "/some-path", + * variant: "outline" + * } + * ] + * }} + * ``` + * + * @see {@link PageHeaderProps} for complete header configuration + * @see {@link PageAction} for detailed action button options + */ + header?: PageHeaderProps + + /** + * Main content for the page + * @remarks + * - Rendered in the main content area below header + * - In 'default' layout: Single column with max-width + * - In 'bento' layout: 3-column grid on desktop, single column on mobile + * + * @example + * ```tsx + * // Single column content + *
+ * Content block 1 + * Content block 2 + *
+ * + * // Bento grid content + * <> + * Wide card + * Sidebar card + * + * ``` + */ + children: ReactNode + + /** + * Layout style for the page + * @remarks + * - 'default': Single-column layout with max-width (4xl) + * - 'bento': Responsive grid layout + * • Desktop: 3-column grid with 1232px max width + * • Mobile: Single column + * @defaultValue "default" + */ + layout?: 'default' | 'bento' + + /** + * Optional CSS classes for the wrapper + * @remarks + * - Applied to the wrapper's root container + * - Combined with default classes using the cn utility + * - Default classes: 'flex flex-col h-full' + * + * @example + * ```tsx + * // Custom background + * className="bg-muted" + * + * // Custom padding + * className="p-8" + * + * // Full height with scrolling content + * className="h-screen overflow-auto" + * ``` + */ + className?: string +} + +/** + * A flexible page layout component that provides consistent structure and styling. + * + * @description + * PageWrapper is a container component that provides a consistent layout structure + * for page content. It supports an optional header section and two layout modes: + * - default: Single-column layout with max-width + * - bento: Grid-based layout with multiple sections + * + * @keywords page-layout, page-container, header-layout, responsive-grid, bento-grid, foundation-component + * @category Layout + * @scope Foundation + * + * @usage + * Common patterns: + * + * Basic page with title only: + * ```tsx + * + *
Simple content
+ *
+ * ``` + * + * Dashboard section with actions: + * ```tsx + * {} }, + * { label: "Filter", variant: "outline", onClick: () => {} } + * ] + * }} + * layout="bento" + * > + * + * + * + * + * + * + * + * ``` + * + * Form page with navigation: + * ```tsx + * + *
+ * + * + *
+ * ``` + * + * Settings page with sections: + * ```tsx + * + * + *

General Settings

+ * + *
+ * + *

Quick Actions

+ * + *
+ *
+ * ``` + * + * @example + * ```tsx + * // Basic usage + * + *
Page content
+ *
+ * + * // With header and custom layout + * console.log("clicked") + * }] + * }} + * layout="bento" + * > + *
Grid-based content
+ *
+ * ``` + * + * @param props - Component props + * @param props.header - Optional header configuration for the page. See {@link PageHeaderProps} for full details + * @param props.children - Main content to be rendered within the wrapper + * @param props.layout - Layout style for content organization ('default' | 'bento') + * @param props.className - Additional CSS classes for the root container + * + * @returns A structured page layout with optional header and content areas + * + * @related {@link PageHeader} - Used internally for header rendering + * @related {@link NavigationWrapper} - Often used as parent component + * @composition Uses {@link cn} for class name merging + * + * @cssUtilities + * - flex-col: Column layout + * - h-full: Full height + * - max-w-4xl: Maximum width for default layout + * - grid-cols-1: Single column on mobile + * - md:grid-cols-3: Three columns on desktop + * + * @accessibility + * - Maintains proper heading hierarchy with h1 in header + * - Preserves content structure for screen readers + * - Supports keyboard navigation through action buttons + * + * @performance + * - Minimal DOM nesting + * - Conditional rendering of header + * - Uses utility classes for styling + */ +export default function PageWrapper({ + header, + children, + layout = 'default', + className +}: PageWrapperProps) { + return ( +
+ {header && ( +
+ +
+ )} +
+
+ {children} +
+
+
+ ) +} diff --git a/apps/deploy-fe/src/components/foundation/page-wrapper/README.md b/apps/deploy-fe/src/components/foundation/page-wrapper/README.md new file mode 100644 index 0000000..20cac18 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/page-wrapper/README.md @@ -0,0 +1,28 @@ +# PageWrapper Component + +A container component for wrapping page content with optional styling. + +## Features + +- Wraps main content +- Customizable styling + +## Usage + +```tsx +import { PageWrapper } from 'path/to/components/foundation/page-wrapper' + +// Basic usage + +
Main content here
+
+``` + +## Props + +See [types.ts](./types.ts) for detailed type definitions. + +| Prop | Type | Description | +|------|------|-------------| +| `children` | `ReactNode` | Main content for the page | +| `className` | `string` | Additional CSS classes | \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/page-wrapper/index.ts b/apps/deploy-fe/src/components/foundation/page-wrapper/index.ts new file mode 100644 index 0000000..15bb838 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/page-wrapper/index.ts @@ -0,0 +1,2 @@ +export { default as PageWrapper } from './PageWrapper' +export type { PageWrapperProps } from './PageWrapper' diff --git a/apps/deploy-fe/src/components/foundation/project-search-bar/ProjectSearchBar.tsx b/apps/deploy-fe/src/components/foundation/project-search-bar/ProjectSearchBar.tsx new file mode 100644 index 0000000..08c89bd --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/project-search-bar/ProjectSearchBar.tsx @@ -0,0 +1,185 @@ +import { Button } from '@workspace/ui/components/button' +import type React from 'react' +import { useEffect, useRef, useState } from 'react' +import type { Project, ProjectSearchBarProps } from './types' + +/** + * A search bar component that allows the user to search for projects. + * This is a simplified version without external dependencies. + * + * @param {ProjectSearchBarProps} props - The props for the component. + * @returns {React.ReactElement} A div element containing the search bar and project list. + */ +export const ProjectSearchBar: React.FC = ({ + onChange, + placeholder = 'Search projects...' +}) => { + const [searchTerm, setSearchTerm] = useState('') + const [isOpen, setIsOpen] = useState(false) + const [items, setItems] = useState([]) + const [selectedIndex, setSelectedIndex] = useState(-1) + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('') + const resultsRef = useRef(null) + + // Mock data - in real implementation this would come from API + const mockProjects: Project[] = [ + { id: '1', name: 'Project Alpha', description: 'A test project' }, + { id: '2', name: 'Project Beta', description: 'Another test project' }, + { id: '3', name: 'Project Gamma', description: 'Yet another test project' }, + { + id: '4', + name: 'Deploy Frontend', + description: 'Frontend deployment project' + }, + { id: '5', name: 'API Service', description: 'Backend API service project' } + ] + + // Handle debounced search term + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearchTerm(searchTerm) + }, 300) + + return () => { + clearTimeout(handler) + } + }, [searchTerm]) + + // Search projects on debounced input change + useEffect(() => { + if (debouncedSearchTerm.trim()) { + const filtered = mockProjects.filter( + (project) => + project.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + project.description + ?.toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) + ) + setItems(filtered) + setIsOpen(filtered.length > 0) + } else { + setItems([]) + setIsOpen(false) + } + }, [debouncedSearchTerm]) + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) return + + // Arrow down + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex((prev: number) => + prev < items.length - 1 ? prev + 1 : prev + ) + } + // Arrow up + else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex((prev: number) => (prev > 0 ? prev - 1 : 0)) + } + // Enter + else if (e.key === 'Enter' && selectedIndex >= 0 && items[selectedIndex]) { + e.preventDefault() + handleSelectItem(items[selectedIndex]) + } + // Escape + else if (e.key === 'Escape') { + e.preventDefault() + setIsOpen(false) + } + } + + // Handle item selection + const handleSelectItem = (project: Project) => { + if (onChange) { + onChange(project) + } + setSearchTerm(project.name) + setIsOpen(false) + setSelectedIndex(-1) + } + + return ( +
+ {/* Search input */} +
+
+ +
+ setSearchTerm(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="w-full pl-8 px-3 py-2 border rounded" + onFocus={() => searchTerm.trim() && setIsOpen(items.length > 0)} + onBlur={() => setTimeout(() => setIsOpen(false), 200)} // Delay to allow clicking on results + aria-expanded={isOpen} + aria-controls={isOpen ? 'project-search-results' : undefined} + aria-label="Search projects" + /> +
+ + {/* Dropdown results */} + {isOpen && ( +
+
+ Suggestions +
+
    + {items.map((project: Project, index: number) => ( + + ))} +
+ {items.length === 0 && ( +
+ No projects found matching "{debouncedSearchTerm}" +
+ )} +
+ )} +
+ ) +} diff --git a/apps/deploy-fe/src/components/foundation/project-search-bar/README.md b/apps/deploy-fe/src/components/foundation/project-search-bar/README.md new file mode 100644 index 0000000..dffbba1 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/project-search-bar/README.md @@ -0,0 +1,12 @@ +# ProjectSearchBar Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { ProjectSearchBar } from '@/components/projectsearchbar'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/foundation/project-search-bar/index.ts b/apps/deploy-fe/src/components/foundation/project-search-bar/index.ts new file mode 100644 index 0000000..3c97651 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/project-search-bar/index.ts @@ -0,0 +1,2 @@ +export * from './ProjectSearchBar' +export * from './types' diff --git a/apps/deploy-fe/src/components/foundation/project-search-bar/types.ts b/apps/deploy-fe/src/components/foundation/project-search-bar/types.ts new file mode 100644 index 0000000..0a01d08 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/project-search-bar/types.ts @@ -0,0 +1,25 @@ +/** + * Simplified Project type to represent project data + */ +export interface Project { + id: string + name: string + description?: string + repoUrl?: string +} + +/** + * ProjectSearchBarProps interface defines the props for the ProjectSearchBar component. + */ +export interface ProjectSearchBarProps { + /** + * Callback function to be called when a project is selected. + * @param data - The selected project data. + */ + onChange?: (data: Project) => void + + /** + * Optional placeholder text for the search input. + */ + placeholder?: string +} diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/README.md b/apps/deploy-fe/src/components/foundation/top-navigation/README.md new file mode 100644 index 0000000..d5071c5 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/README.md @@ -0,0 +1,33 @@ +# TopNavigation Component + +A component for the top navigation bar, providing navigation controls and additional features like dark mode toggle and wallet session badge. + +## Features + +- Navigation controls +- Dark mode toggle +- Wallet session badge +- Customizable styling + +## Usage + +```tsx +import { TopNavigation } from 'path/to/components/foundation/top-navigation' + +// Basic usage + +``` + +## Props + +See [types.ts](./types.ts) for detailed type definitions. + +| Prop | Type | Description | +|------|------|-------------| +| `className` | `string` | Additional CSS classes | + +## Additional Components + +- **DarkModeToggle**: Toggles between light and dark themes +- **WalletSessionBadge**: Displays wallet session status +- **NavigationItem**: Represents individual navigation items \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/DarkModeToggle.tsx b/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/DarkModeToggle.tsx new file mode 100644 index 0000000..39e9d3e --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/DarkModeToggle.tsx @@ -0,0 +1,40 @@ +'use client' + +import { Button } from '@workspace/ui/components/button' +import { Moon, Sun } from 'lucide-react' +import { useTheme } from 'next-themes' +import { useEffect, useState } from 'react' + +export function DarkModeToggle() { + const { setTheme } = useTheme() + const [mounted, setMounted] = useState(false) + const [isDark, setIsDark] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + useEffect(() => { + if (mounted) { + setIsDark(document.documentElement.classList.contains('dark')) + } + }, [mounted]) + + const handleThemeToggle = () => { + const newTheme = isDark ? 'light' : 'dark' + setTheme(newTheme) + setIsDark(!isDark) + } + + return ( + + ) +} diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/README.md b/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/README.md new file mode 100644 index 0000000..575ebcc --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/README.md @@ -0,0 +1,22 @@ +# DarkModeToggle Component + +A button component to toggle between light and dark themes. + +## Features + +- Toggles between light and dark themes +- Uses `next-themes` for theme management +- Customizable styling + +## Usage + +```tsx +import { DarkModeToggle } from 'path/to/components/foundation/top-navigation/dark-mode-toggle' + +// Basic usage + +``` + +## Props + +This component does not accept any props. \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/index.ts b/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/index.ts new file mode 100644 index 0000000..7e5abe1 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/index.ts @@ -0,0 +1 @@ +export { DarkModeToggle } from './DarkModeToggle' diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/index.ts b/apps/deploy-fe/src/components/foundation/top-navigation/index.ts new file mode 100644 index 0000000..c614eac --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/index.ts @@ -0,0 +1,5 @@ +export { + default as TopNavigation, + type TopNavigationProps +} from './main-navigation/MainNavigation' +export type { NavigationItemConfig, TopNavigationConfig } from './types' diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/MainNavigation.tsx b/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/MainNavigation.tsx new file mode 100644 index 0000000..20c6371 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/MainNavigation.tsx @@ -0,0 +1,300 @@ +'use client' + +import { LaconicMark } from '@/components/assets/laconic-mark' +import { UserButton } from '@clerk/nextjs' +import { Button } from '@workspace/ui/components/button' +import { + Sheet, + SheetClose, + SheetContent, + SheetTitle, + SheetTrigger +} from '@workspace/ui/components/sheet' +import { CreditCard, Menu, Shapes, WalletIcon } from 'lucide-react' +import Link from 'next/link' +import type React from 'react' +import { DarkModeToggle } from '../dark-mode-toggle' +import { NavigationItem } from '../navigation-item' +import type { TopNavigationConfig } from '../types' +import { WalletSessionBadge } from '../wallet-session-badge' + +/** + * Props for the TopNavigation component + * @remarks + * Configuration interface for TopNavigation, a layout component that provides: + * - Responsive navigation bar with mobile drawer + * - Left and right navigation items + * - Dark mode toggle + * - User authentication button + * - Wallet session badge + * - Logo/home link + * + * @see {@link TopNavigation} for the component implementation + */ +export interface TopNavigationProps { + /** + * Configuration for navigation items and layout + * @remarks + * - Defines left and right navigation items + * - Each item can have label, href/onClick, icon, and active state + * - Items are rendered as buttons or links based on presence of href + * - Mobile view combines all items into a drawer menu + * + * @example + * ```tsx + * config={{ + * leftItems: [ + * { label: 'Projects', href: '/projects', active: true }, + * { label: 'Wallet', href: '/wallets', icon: WalletIcon } + * ], + * rightItems: [ + * { label: 'Support', href: '/support' }, + * { + * label: 'Documentation', + * onClick: () => window.open('https://docs.example.com') + * } + * ] + * }} + * ``` + * + * @defaultValue + * ```tsx + * { + * leftItems: [ + * { label: 'Projects', href: '/projects' }, + * { label: 'Wallet', href: '/wallets' } + * ], + * rightItems: [ + * { label: 'Support', href: '/support' }, + * { label: 'Documentation', href: '/documentation' } + * ] + * } + * ``` + */ + config?: TopNavigationConfig + + /** + * Optional child elements + * @remarks + * - Can be used to add custom elements to the navigation + * - Rendered after the default navigation items + * - Not commonly used as the config prop handles most use cases + */ + children?: React.ReactNode +} + +/** + * A responsive navigation bar component with mobile support and integrated features. + * + * @description + * TopNavigation is a foundational component that provides: + * - Responsive navigation with mobile drawer menu + * - Configurable left and right navigation items + * - Integrated dark mode toggle + * - User authentication button + * - Wallet session display + * - Logo/home link + * + * @keywords navigation, header, responsive, mobile-menu, foundation-component + * @category Navigation + * @scope Foundation + * + * @usage + * Common patterns: + * + * Basic navigation: + * ```tsx + * + * ``` + * + * With active states and icons: + * ```tsx + * + * ``` + * + * With click handlers: + * ```tsx + * setHelpOpen(true), + * icon: HelpIcon + * } + * ] + * }} + * /> + * ``` + * + * @example + * ```tsx + * // Basic usage + * + * + * // Custom navigation items + * + * ``` + * + * @param props - Component props + * @param props.config - Navigation configuration object + * @param props.children - Optional child elements + * + * @returns A responsive navigation bar with mobile support + * + * @related {@link NavigationItem} - Used for individual nav items + * @related {@link DarkModeToggle} - Integrated dark mode control + * @related {@link WalletSessionBadge} - Displays wallet info + * @composition Uses {@link Sheet} for mobile menu + * + * @cssUtilities + * - sticky: Fixed to top of viewport + * - z-50: High z-index for overlay + * - border-b: Bottom border + * - bg-background: Theme-aware background + * - text-foreground: Theme-aware text + * + * @accessibility + * - Uses semantic header and nav elements + * - Includes sr-only labels for screen readers + * - Supports keyboard navigation + * - Mobile menu follows drawer pattern + * + * @performance + * - Conditionally renders mobile/desktop views + * - Uses CSS utilities for styling + * - Lazy loads mobile drawer content + */ +export default function TopNavigation({ + config = { + leftItems: [ + { icon: Shapes, label: 'Projects', href: '/projects' }, + { icon: WalletIcon, label: 'Wallet', href: '/wallet' }, + { icon: CreditCard, label: 'Purchase', href: '/purchase' } + ], + rightItems: [ + { label: 'Support', href: '/support' }, + { label: 'Documentation', href: '/documentation' } + ] + } +}: TopNavigationProps) { + const { leftItems = [], rightItems = [] } = config + + return ( +
+
+ {/* Mobile menu trigger - positioned on the left */} +
+ + + + + + Navigation Menu +
+ + + + + Close + +
+
+ +
+
+
+
+ + + + + +
+ {leftItems.map((item) => ( + + {item.label} + + ))} +
+ +
+ + + + + +
+
+
+ ) +} diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/README.md b/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/README.md new file mode 100644 index 0000000..a2253bf --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/README.md @@ -0,0 +1,32 @@ +# MainNavigation Component + +A component for the main navigation bar, providing navigation controls and additional features like dark mode toggle and wallet session badge. + +## Features + +- Navigation controls +- Dark mode toggle +- Wallet session badge +- Customizable styling + +## Usage + +```tsx +import { TopNavigation } from 'path/to/components/foundation/top-navigation/main-navigation' + +// Basic usage + +``` + +## Props + +| Prop | Type | Description | +|------|------|-------------| +| `config` | `TopNavigationConfig` | Configuration for navigation items | +| `children` | `ReactNode` | Additional elements to render | + +## Additional Components + +- **DarkModeToggle**: Toggles between light and dark themes +- **WalletSessionBadge**: Displays wallet session status +- **NavigationItem**: Represents individual navigation items \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/index.ts b/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/index.ts new file mode 100644 index 0000000..81b6cfd --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/index.ts @@ -0,0 +1 @@ +export { default as TopNavigation } from './MainNavigation' diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/NavigationItem.tsx b/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/NavigationItem.tsx new file mode 100644 index 0000000..e464ab6 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/NavigationItem.tsx @@ -0,0 +1,315 @@ +'use client' + +import { Button } from '@workspace/ui/components/button' +import { cn } from '@workspace/ui/lib/utils' +import type { LucideIcon } from 'lucide-react' +import Link from 'next/link' +import type React from 'react' + +/** + * Props for the NavigationItem component + * @remarks + * Configuration interface for NavigationItem, a flexible navigation element that: + * - Supports both link and button behaviors + * - Handles mobile drawer and desktop navigation styles + * - Includes icon support + * - Provides active state styling + * - Uses shadcn/ui Button component for consistent styling + * + * @see {@link NavigationItem} for the component implementation + */ +export interface NavigationItemProps { + /** + * URL for link navigation + * @remarks + * - When provided, renders as a Next.js Link + * - Takes precedence over onClick + * - Uses Next.js routing for client-side navigation + * + * @example + * ```tsx + * href="/dashboard" + * href="/settings/profile" + * ``` + */ + href?: string + + /** + * Click handler for button behavior + * @remarks + * - Used when href is not provided + * - Renders as a button element + * - Useful for actions that don't navigate + * + * @example + * ```tsx + * onClick={() => setIsOpen(true)} + * onClick={() => handleLogout()} + * ``` + */ + onClick?: () => void + + /** + * Optional CSS classes + * @remarks + * - Applied to the root element + * - Combined with default classes using cn utility + * - Different defaults for drawer vs regular items + * + * @example + * ```tsx + * className="text-primary" + * className="hidden lg:flex" + * ``` + */ + className?: string + + /** + * Active state flag + * @remarks + * - Adds semibold font weight when true + * - In drawer mode, also changes text color + * - Use for current page/section indication + * + * @example + * ```tsx + * active={pathname === '/dashboard'} + * active={section === 'settings'} + * ``` + * + * @defaultValue false + */ + active?: boolean + + /** + * Optional Lucide icon component + * @remarks + * - Rendered before children when provided + * - Only shown in desktop navigation + * - Sized and spaced automatically + * + * @example + * ```tsx + * icon={HomeIcon} + * icon={Settings} + * ``` + */ + icon?: LucideIcon + + /** + * Content of the navigation item + * @remarks + * - Typically a text label + * - Can include other elements + * - Positioned after icon if present + * + * @example + * ```tsx + * children="Dashboard" + * children={<>Home New} + * ``` + */ + children: React.ReactNode + + /** + * Button variant from shadcn/ui + * @remarks + * - Only applies to desktop navigation + * - Drawer items use custom styling + * - Uses shadcn/ui Button variants + * + * @example + * ```tsx + * variant="default" + * variant="ghost" + * ``` + * + * @defaultValue "ghost" + */ + variant?: + | 'default' + | 'destructive' + | 'outline' + | 'secondary' + | 'ghost' + | 'link' + + /** + * Flag for drawer-specific styling + * @remarks + * - When true, uses simpler drawer-specific styles + * - Affects hover and active states + * - Changes padding and spacing + * + * @defaultValue false + */ + isDrawerItem?: boolean +} + +/** + * A flexible navigation item component that adapts to mobile and desktop contexts. + * + * @description + * NavigationItem is a foundational component that: + * - Renders as either a link or button + * - Adapts styling for mobile drawer or desktop navigation + * - Supports icons and active states + * - Maintains consistent styling with shadcn/ui + * + * @keywords navigation, link, button, responsive, foundation-component + * @category Navigation + * @scope Foundation + * + * @usage + * Common patterns: + * + * Basic link: + * ```tsx + * + * Dashboard + * + * ``` + * + * With icon and active state: + * ```tsx + * + * Settings + * + * ``` + * + * As a button with click handler: + * ```tsx + * setIsOpen(true)} + * variant="ghost" + * > + * Open Menu + * + * ``` + * + * In mobile drawer: + * ```tsx + * + * Profile + * + * ``` + * + * @example + * ```tsx + * // Basic usage + * Home + * + * // With all props + * + * Settings + * + * ``` + * + * @param props - Component props + * @param props.href - URL for link navigation + * @param props.onClick - Click handler for button behavior + * @param props.className - Additional CSS classes + * @param props.active - Active state flag + * @param props.icon - Optional Lucide icon component + * @param props.children - Content of the navigation item + * @param props.variant - Button variant from shadcn/ui + * @param props.isDrawerItem - Flag for drawer-specific styling + * + * @returns A navigation item as either a link or button + * + * @related {@link TopNavigation} - Parent component + * @composition Uses {@link Button} from shadcn/ui + * + * @cssUtilities + * Desktop: + * - font-semibold: Applied when active + * - mr-2: Icon margin + * - h-4 w-4: Icon size + * + * Drawer: + * - px-6 py-1: Padding + * - text-sm: Font size + * - font-medium: Font weight + * - hover:text-white/80: Hover state + * + * @accessibility + * - Maintains button/link semantics + * - Preserves keyboard navigation + * - Supports screen readers + * - Indicates current page + * + * @performance + * - Conditional rendering based on props + * - Uses CSS utilities + * - Minimal state management + */ +export function NavigationItem({ + href, + onClick, + className, + active = false, + icon: Icon, + children, + variant = 'ghost', + isDrawerItem = false +}: NavigationItemProps) { + // For drawer items, use a simpler styling approach + if (isDrawerItem) { + const content = ( +
+ {children} +
+ ) + + if (href) { + return {content} + } + + return + } + + // For regular navigation items, use the Button component + const content = ( + <> + {Icon && } + {children} + + ) + + const buttonClassName = cn(active && 'font-semibold', className) + + if (href) { + return ( + + ) + } + + return ( + + ) +} diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/README.md b/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/README.md new file mode 100644 index 0000000..8c1a83c --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/README.md @@ -0,0 +1,21 @@ +# NavigationItem Component + +A component representing an individual navigation item. + +## Features + +- Represents a navigation item +- Customizable styling + +## Usage + +```tsx +import { NavigationItem } from 'path/to/components/foundation/top-navigation/navigation-item' + +// Basic usage + +``` + +## Props + +This component does not accept any props. \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/index.ts b/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/index.ts new file mode 100644 index 0000000..2668383 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/index.ts @@ -0,0 +1 @@ +export { NavigationItem, type NavigationItemProps } from './NavigationItem' diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/types.ts b/apps/deploy-fe/src/components/foundation/top-navigation/types.ts new file mode 100644 index 0000000..f1a8e5d --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/types.ts @@ -0,0 +1,14 @@ +import type { LucideIcon } from 'lucide-react' + +export interface NavigationItemConfig { + label: string + href?: string + onClick?: () => void + icon?: LucideIcon + active?: boolean +} + +export interface TopNavigationConfig { + leftItems?: NavigationItemConfig[] + rightItems?: NavigationItemConfig[] +} diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/README.md b/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/README.md new file mode 100644 index 0000000..44d4527 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/README.md @@ -0,0 +1,21 @@ +# WalletSessionBadge Component + +A component to display the wallet session status. + +## Features + +- Displays wallet session status +- Customizable styling + +## Usage + +```tsx +import { WalletSessionBadge } from 'path/to/components/foundation/top-navigation/wallet-session-badge' + +// Basic usage + +``` + +## Props + +This component does not accept any props. \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/WalletSessionBadge.tsx b/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/WalletSessionBadge.tsx new file mode 100644 index 0000000..c042eda --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/WalletSessionBadge.tsx @@ -0,0 +1,175 @@ +// 'use client' + +// import { Button } from '@workspace/ui/components/button' +// import { +// DropdownMenu, +// DropdownMenuContent, +// DropdownMenuItem, +// DropdownMenuTrigger +// } from '@workspace/ui/components/dropdown-menu' +// import { cn } from '@workspace/ui/lib/utils' +// import { ChevronDown, LogOut } from 'lucide-react' +// import { useState } from 'react' + +// interface WalletSessionBadgeProps { +// address: string +// className?: string +// } + +// export function WalletSessionBadge({ +// address, +// className +// }: WalletSessionBadgeProps) { +// const [isConnected, setIsConnected] = useState(true) + +// return ( +// +// +// +// +// +// setIsConnected(false)} +// > +// +// Disconnect +// +// +// +// ) +// } + +'use client' + +import { useWallet } from '@/context/WalletContext' +import { Button } from '@workspace/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@workspace/ui/components/dropdown-menu' +import { cn } from '@workspace/ui/lib/utils' +import { ChevronDown, LogOut } from 'lucide-react' +import useCheckBalance from '@/components/iframe/check-balance-iframe/useCheckBalance' +import { useEffect } from 'react' + +const IFRAME_ID = 'checkBalanceIframe' + +export function WalletSessionBadge({ className }: { className?: string }) { + const { wallet, isConnected, connect, disconnect } = useWallet() + const { isBalanceSufficient, checkBalance } = useCheckBalance("1", IFRAME_ID) + + // Check balance when wallet connects + useEffect(() => { + if (isConnected) { + checkBalance() + } + }, [isConnected, checkBalance]) + + // Format address for display (first 6 chars + ... + last 4 chars) + const formatAddress = (address?: string) => { + if (!address) return 'Connect Wallet' + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}` + } + + // Determine the status indicator color based on connection and balance + const getStatusColor = () => { + if (!isConnected) return 'bg-red-500' + if (isBalanceSufficient === false) return 'bg-yellow-500' + if (isBalanceSufficient === true) return 'bg-green-500' + return 'bg-blue-500' // Checking balance + } + + return ( + + + + + + {isConnected ? ( + <> +
+

Connected to:

+

{wallet?.address}

+

+ Balance: {isBalanceSufficient === undefined ? 'Checking...' : + isBalanceSufficient ? 'Sufficient' : 'Insufficient'} +

+
+ + + Disconnect + + + ) : ( + + Connect Wallet + + )} +
+
+ ) +} \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/index.ts b/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/index.ts new file mode 100644 index 0000000..68ffea5 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/index.ts @@ -0,0 +1 @@ +export { WalletSessionBadge } from './WalletSessionBadge' diff --git a/apps/deploy-fe/src/components/foundation/types.ts b/apps/deploy-fe/src/components/foundation/types.ts new file mode 100644 index 0000000..9193ca4 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/types.ts @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react' +import type { PageAction, PageHeaderProps } from './page-header' + +/** + * Props for the NavigationWrapper component + * @remarks + * Container component that wraps page content with navigation functionality. + * Typically used as the outer container for PageWrapper components. + */ +export type NavigationWrapperProps = { + /** + * Content to be displayed within the navigation wrapper + * @remarks + * - Typically contains {@link PageWrapper} components + * - Can include any valid React nodes + */ + children: ReactNode + + /** + * Optional CSS class name for custom styling + * @remarks Applied to the wrapper's root container element + */ + className?: string +} + +// Re-export types from component folders +export type { PageAction, PageHeaderProps } + +/** + * Layout options for page content + * @remarks + + * - default: Single-column layout + * - bento: Grid-based layout with multiple sections + */ +export type PageWrapperLayout = 'default' | 'bento' + +/** + * Configuration for the main page container + * @property {PageHeaderProps} header - Header configuration for the page + * @property {ReactNode} children - Main content for the page + * @property {PageWrapperLayout} layout - Layout style for the page + * @property {string} className - Custom CSS classes for the wrapper + * @property {string} contentClassName - Custom CSS classes for the content area + * Main container for page content with optional header, flexible layout options, + * and customizable styling for both wrapper and content areas. + */ +export interface PageWrapperProps { + /** + * Header configuration for the page + * @see {@link PageHeaderProps} + */ + header?: PageHeaderProps + /** + * Main content for the page + * @remarks Rendered in the main content area below header + */ + children: ReactNode + /** + * Layout style for the page + * @see {@link PageWrapperLayout} + * @defaultValue "default" + */ + layout?: PageWrapperLayout + /** + * Optional CSS classes for the wrapper + * @remarks Applied to the wrapper's root container + */ + className?: string + /** + * Optional CSS classes for the content area + * @remarks + * - Applied to the main content container + * - Separate from the wrapper's root className + */ + contentClassName?: string +} diff --git a/apps/deploy-fe/src/components/foundation/wallet-session-id/README.md b/apps/deploy-fe/src/components/foundation/wallet-session-id/README.md new file mode 100644 index 0000000..dd89995 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/wallet-session-id/README.md @@ -0,0 +1,12 @@ +# WalletSessionId Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { WalletSessionId } from '@/components/walletsessionid'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/foundation/wallet-session-id/WalletSessionId.tsx b/apps/deploy-fe/src/components/foundation/wallet-session-id/WalletSessionId.tsx new file mode 100644 index 0000000..62bcffe --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/wallet-session-id/WalletSessionId.tsx @@ -0,0 +1,105 @@ +// import type React from 'react' +// import type { WalletSessionIdProps } from './types' + +// /** +// * A component that displays the wallet session ID with a connection status indicator. +// * +// * @param {WalletSessionIdProps} props - The props for the component. +// * @returns {React.ReactElement} A div element containing the wallet session ID. +// */ +// export const WalletSessionId: React.FC = ({ +// walletId, +// className = '', +// isConnected = true +// }) => { +// // For demonstration, use provided wallet ID or a placeholder +// const displayId = walletId || 'x123xxx' + +// return ( +//
+//
+// {displayId} +//
+// ) +// } + +'use client' + +import { useWallet } from '@/context/WalletContext' // or WalletContextProvider +import { Button } from '@workspace/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@workspace/ui/components/dropdown-menu' +import { cn } from '@workspace/ui/lib/utils' +import { ChevronDown, LogOut } from 'lucide-react' + +export function WalletSessionBadge({ className }: { className?: string }) { + const { wallet, isConnected, connect, disconnect } = useWallet() + + // Format address for display (first 6 chars + ... + last 4 chars) + const formatAddress = (address?: string) => { + if (!address) return 'Connect Wallet' + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}` + } + + return ( + + + + + + {isConnected ? ( + + + Disconnect + + ) : ( + + Connect Wallet + + )} + + + ) +} \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/wallet-session-id/index.ts b/apps/deploy-fe/src/components/foundation/wallet-session-id/index.ts new file mode 100644 index 0000000..372858c --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/wallet-session-id/index.ts @@ -0,0 +1,2 @@ +export * from './WalletSessionId' +export * from './types' diff --git a/apps/deploy-fe/src/components/foundation/wallet-session-id/types.ts b/apps/deploy-fe/src/components/foundation/wallet-session-id/types.ts new file mode 100644 index 0000000..a07b6fa --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/wallet-session-id/types.ts @@ -0,0 +1,20 @@ +/** + * WalletSessionIdProps interface defines the props for the WalletSessionId component. + */ +export interface WalletSessionIdProps { + /** + * The wallet ID to display. + */ + walletId?: string + + /** + * Optional CSS class names to apply to the component. + */ + className?: string + + /** + * Whether the wallet is connected. + * @default true + */ + isConnected?: boolean +} diff --git a/apps/deploy-fe/src/components/iframe/auto-sign-in/AutoSignInIFrameModal.tsx b/apps/deploy-fe/src/components/iframe/auto-sign-in/AutoSignInIFrameModal.tsx new file mode 100644 index 0000000..72eb14c --- /dev/null +++ b/apps/deploy-fe/src/components/iframe/auto-sign-in/AutoSignInIFrameModal.tsx @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useState } from 'react' +// Commenting out these imports as they cause linter errors due to missing dependencies +// In an actual implementation, these would be properly installed +// import { generateNonce, SiweMessage } from 'siwe' +// import axios from 'axios' + +// Define proper types to replace 'any' +interface SiweMessageProps { + version: string + domain: string + uri: string + chainId: number + address: string + nonce: string + statement: string +} + +interface ValidateRequestData { + message: string + signature: string +} + +// Mock implementations to demonstrate functionality without dependencies +// In a real project, use the actual dependencies +const generateNonce = () => Math.random().toString(36).substring(2, 15) +const SiweMessage = class { + constructor(props: SiweMessageProps) { + this.props = props + } + props: SiweMessageProps + prepareMessage() { + return JSON.stringify(this.props) + } +} + +// Access environment variables from .env.local with fallbacks for safety +// In a production environment, these would be properly configured +const WALLET_IFRAME_URL = + process.env.NEXT_PUBLIC_WALLET_IFRAME_URL || 'https://wallet.example.com' + +// Mock axios implementation +const axiosInstance = { + post: async (url: string, data: ValidateRequestData) => { + console.log('Mock API call to', url, 'with data', data) + return { data: { success: true } } + } +} + +/** + * AutoSignInIFrameModal component that handles wallet authentication through an iframe. + * This component is responsible for: + * 1. Getting the wallet address + * 2. Creating a Sign-In With Ethereum message + * 3. Requesting signature from the wallet + * 4. Validating the signature with the backend + * + * @returns {JSX.Element} A modal with an iframe for wallet authentication + */ +export function AutoSignInIFrameModal() { + const [accountAddress, setAccountAddress] = useState() + + // Handle sign-in response from the wallet iframe + useEffect(() => { + const handleSignInResponse = async (event: MessageEvent) => { + if (event.origin !== WALLET_IFRAME_URL) return + + if (event.data.type === 'SIGN_IN_RESPONSE') { + try { + const response = await axiosInstance.post('/auth/validate', { + message: event.data.data.message, + signature: event.data.data.signature + }) + + if (response.data.success === true) { + // In Next.js, we would use router.push instead + window.location.href = '/' + } + } catch (error) { + console.error('Error signing in:', error) + } + } + } + + window.addEventListener('message', handleSignInResponse) + + return () => { + window.removeEventListener('message', handleSignInResponse) + } + }, []) + + // Initiate auto sign-in when account address is available + useEffect(() => { + const initiateAutoSignIn = async () => { + if (!accountAddress) return + + const iframe = document.getElementById( + 'walletAuthFrame' + ) as HTMLIFrameElement + + if (!iframe.contentWindow) { + console.error('Iframe not found or not loaded') + return + } + + const message = new SiweMessage({ + version: '1', + domain: window.location.host, + uri: window.location.origin, + chainId: 1, + address: accountAddress, + nonce: generateNonce(), + statement: 'Sign in With Ethereum.' + }).prepareMessage() + + iframe.contentWindow.postMessage( + { + type: 'AUTO_SIGN_IN', + chainId: '1', + message + }, + WALLET_IFRAME_URL + ) + } + + initiateAutoSignIn() + }, [accountAddress]) + + // Listen for wallet accounts data + useEffect(() => { + const handleAccountsDataResponse = async (event: MessageEvent) => { + if (event.origin !== WALLET_IFRAME_URL) return + + if ( + event.data.type === 'WALLET_ACCOUNTS_DATA' && + event.data.data?.length > 0 + ) { + setAccountAddress(event.data.data[0].address) + } + } + + window.addEventListener('message', handleAccountsDataResponse) + + return () => { + window.removeEventListener('message', handleAccountsDataResponse) + } + }, []) + + // Request wallet address when iframe is loaded + const getAddressFromWallet = useCallback(() => { + const iframe = document.getElementById( + 'walletAuthFrame' + ) as HTMLIFrameElement + + if (!iframe.contentWindow) { + console.error('Iframe not found or not loaded') + return + } + + iframe.contentWindow.postMessage( + { + type: 'REQUEST_CREATE_OR_GET_ACCOUNTS', + chainId: '1' + }, + WALLET_IFRAME_URL + ) + }, []) + + return ( +
+
+