From 8cb6580d1152b9e9179c20901c8e50fd93690550 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 2 Apr 2026 11:32:32 -0400 Subject: [PATCH 1/8] fix(global): resolve authorize user from os account --- src/commands/global.ts | 5 ----- tests/global-command.macos.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/commands/global.ts b/src/commands/global.ts index c7f70162..fbebee16 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -844,11 +844,6 @@ function renderMacDnsSudoers(opts: { } async function resolveCurrentUsername(): Promise { - const envUser = process.env.USER?.trim(); - if (envUser) { - return envUser; - } - const whoami = await exec(["id", "-un"], { stdin: "ignore" }); if (whoami.exitCode !== 0) { return null; diff --git a/tests/global-command.macos.test.ts b/tests/global-command.macos.test.ts index 7441e642..c3e6faeb 100644 --- a/tests/global-command.macos.test.ts +++ b/tests/global-command.macos.test.ts @@ -19,7 +19,9 @@ let runResponder: ((cmd: readonly string[]) => number | null) | null = null; let tempDir: string | null = null; let originalHome: string | undefined; let originalLogger: string | undefined; +let originalUser: string | undefined; let reachabilityByHost: Record = {}; +let idUser = "mock-user"; mock.module("@clack/prompts", () => ({ access: async () => true, @@ -83,6 +85,9 @@ mock.module("../src/lib/shell.ts", () => ({ if (cmd[0] === "docker" && cmd[1] === "network" && cmd[2] === "inspect") { return { exitCode: 0, stdout: "[]", stderr: "" }; } + if (cmd[0] === "id" && cmd[1] === "-un") { + return { exitCode: 0, stdout: `${idUser}\n`, stderr: "" }; + } return { exitCode: 0, stdout: "", stderr: "" }; }, @@ -111,12 +116,15 @@ mock.module("../src/lib/os.ts", () => ({ beforeEach(async () => { originalHome = process.env.HOME; originalLogger = process.env.HACK_LOGGER; + originalUser = process.env.USER; tempDir = await mkdtemp(join(tmpdir(), "hack-global-macos-")); process.env.HOME = tempDir; + process.env.USER = "env-user"; process.env.HACK_LOGGER = "console"; runCalls.length = 0; runResponder = null; reachabilityByHost = {}; + idUser = "mock-user"; }); afterEach(async () => { @@ -125,6 +133,7 @@ afterEach(async () => { tempDir = null; } process.env.HOME = originalHome; + process.env.USER = originalUser; process.env.HACK_LOGGER = originalLogger; }); @@ -235,6 +244,25 @@ test("global authorize installs passwordless dns recovery sudoers rule", async ( ); }); +test("global authorize uses the OS account lookup instead of USER env", async () => { + idUser = "real-login-user"; + process.env.USER = "spoofed-user"; + + const { runCli } = await import("../src/cli/run.ts"); + const code = await runCli(["global", "authorize"]); + + expect(code).toBe(0); + const sudoersPath = resolve( + tempDir!, + GLOBAL_HACK_DIR_NAME, + "tmp", + "dance.hack-dns-recovery.sudoers" + ); + const sudoersText = await Bun.file(sudoersPath).text(); + expect(sudoersText).toContain("real-login-user ALL = (root) NOPASSWD:"); + expect(sudoersText).not.toContain("spoofed-user ALL = (root) NOPASSWD:"); +}); + test("global up tries passwordless sudo before prompting for dnsmasq recovery", async () => { const caddyCompose = join( tempDir!, From 939c2b2cb680744707b1d085db0bc0ce8fac32e4 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 2 Apr 2026 13:25:45 -0400 Subject: [PATCH 2/8] feat: refresh web app and streamline install workflows --- .github/workflows/release.yml | 49 ++ .gitignore | 7 + README.md | 112 ++- apps/web/app/globals.css | 178 ----- apps/web/app/layout.tsx | 19 - apps/web/app/page.tsx | 11 - apps/web/components.json | 18 +- apps/web/package.json | 10 +- apps/web/src/app/account/layout.tsx | 9 + apps/web/{ => src}/app/account/page.tsx | 4 +- .../app/api/auth/flows/[flowId]/route.ts | 2 +- .../{ => src}/app/api/auth/providers/route.ts | 2 +- .../{ => src}/app/api/auth/social/route.ts | 4 +- .../invitations/[inviteId]/accept/route.ts | 6 +- .../invitations/[inviteId]/decline/route.ts | 6 +- .../orgs/[org]/members/invite/route.ts | 6 +- .../orgs/[org]/members/remove/route.ts | 6 +- .../app/api/control-plane/orgs/route.ts | 6 +- .../projects/[project]/access/grant/route.ts | 6 +- .../projects/[project]/access/revoke/route.ts | 6 +- .../app/api/control-plane/projects/route.ts | 6 +- .../teams/[team]/members/invite/route.ts | 6 +- .../teams/[team]/members/remove/route.ts | 6 +- .../app/api/control-plane/teams/route.ts | 6 +- apps/web/{ => src}/app/auth/account/page.tsx | 6 +- apps/web/{ => src}/app/auth/page.tsx | 4 +- apps/web/src/app/globals.css | 171 ++++ apps/web/src/app/layout.tsx | 30 + apps/web/src/app/page.tsx | 13 + apps/web/src/app/providers.tsx | 14 + .../account-control-plane-sections.tsx | 16 +- .../src/components/account-shell-loading.tsx | 4 +- .../web/src/components/account-shell-page.tsx | 14 +- apps/web/src/components/app-navbar.tsx | 41 + apps/web/src/components/app-shell.tsx | 15 + apps/web/src/components/app-sidebar.tsx | 189 +++++ apps/web/src/components/auth-entrypoint.tsx | 139 +++- apps/web/src/components/big-logo.tsx | 145 ++++ .../src/components/control-plane-shell.tsx | 16 +- .../src/components/custom-sidebar-trigger.tsx | 24 + .../web/src/components/dashboard-skeleton.tsx | 20 + apps/web/src/components/leatest-change.tsx | 56 ++ .../components/linear-management-section.tsx | 6 +- apps/web/src/components/logo.tsx | 31 + apps/web/src/components/marketing-chrome.tsx | 17 + apps/web/src/components/mode-toggle.tsx | 63 ++ apps/web/src/components/nav-user.tsx | 83 ++ apps/web/src/components/theme-provider.tsx | 17 + apps/web/src/components/ui/avatar.tsx | 109 +++ apps/web/src/components/ui/breadcrumb.tsx | 107 +++ apps/web/src/components/ui/button.tsx | 65 ++ apps/web/src/components/ui/dropdown-menu.tsx | 257 ++++++ apps/web/src/components/ui/input.tsx | 21 + apps/web/src/components/ui/kbd.tsx | 28 + apps/web/src/components/ui/separator.tsx | 28 + apps/web/src/components/ui/sheet.tsx | 143 ++++ apps/web/src/components/ui/sidebar.tsx | 733 ++++++++++++++++++ apps/web/src/components/ui/skeleton.tsx | 13 + apps/web/src/components/ui/tooltip.tsx | 57 ++ apps/web/src/hooks/use-mobile.ts | 21 + apps/web/src/lib/utils.ts | 2 +- .../account-control-plane-routes.test.ts | 16 +- .../tests/account-shell-hydration.test.tsx | 2 +- apps/web/tests/auth-entrypoints.test.tsx | 6 +- apps/web/tests/control-plane-shell.test.tsx | 10 +- apps/web/tests/package-manifest.test.ts | 5 +- .../project-control-plane-routes.test.ts | 6 +- apps/web/tsconfig.json | 2 +- assets/header.svg | 174 +++++ bun.lock | 542 ++++++++++++- scripts/install-status.ts | 100 ++- scripts/update-homebrew-tap.ts | 483 ++++++++++++ src/commands/update.ts | 69 +- src/lib/self-update.ts | 129 ++- tests/env.test.ts | 35 + tests/install-detection.test.ts | 76 ++ tsconfig.json | 1 - 77 files changed, 4371 insertions(+), 494 deletions(-) delete mode 100644 apps/web/app/globals.css delete mode 100644 apps/web/app/layout.tsx delete mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/src/app/account/layout.tsx rename apps/web/{ => src}/app/account/page.tsx (77%) rename apps/web/{ => src}/app/api/auth/flows/[flowId]/route.ts (95%) rename apps/web/{ => src}/app/api/auth/providers/route.ts (91%) rename apps/web/{ => src}/app/api/auth/social/route.ts (96%) rename apps/web/{ => src}/app/api/control-plane/invitations/[inviteId]/accept/route.ts (90%) rename apps/web/{ => src}/app/api/control-plane/invitations/[inviteId]/decline/route.ts (90%) rename apps/web/{ => src}/app/api/control-plane/orgs/[org]/members/invite/route.ts (92%) rename apps/web/{ => src}/app/api/control-plane/orgs/[org]/members/remove/route.ts (92%) rename apps/web/{ => src}/app/api/control-plane/orgs/route.ts (93%) rename apps/web/{ => src}/app/api/control-plane/projects/[project]/access/grant/route.ts (95%) rename apps/web/{ => src}/app/api/control-plane/projects/[project]/access/revoke/route.ts (94%) rename apps/web/{ => src}/app/api/control-plane/projects/route.ts (95%) rename apps/web/{ => src}/app/api/control-plane/teams/[team]/members/invite/route.ts (93%) rename apps/web/{ => src}/app/api/control-plane/teams/[team]/members/remove/route.ts (92%) rename apps/web/{ => src}/app/api/control-plane/teams/route.ts (93%) rename apps/web/{ => src}/app/auth/account/page.tsx (84%) rename apps/web/{ => src}/app/auth/page.tsx (86%) create mode 100644 apps/web/src/app/globals.css create mode 100644 apps/web/src/app/layout.tsx create mode 100644 apps/web/src/app/page.tsx create mode 100644 apps/web/src/app/providers.tsx create mode 100644 apps/web/src/components/app-navbar.tsx create mode 100644 apps/web/src/components/app-shell.tsx create mode 100644 apps/web/src/components/app-sidebar.tsx create mode 100644 apps/web/src/components/big-logo.tsx create mode 100644 apps/web/src/components/custom-sidebar-trigger.tsx create mode 100644 apps/web/src/components/dashboard-skeleton.tsx create mode 100644 apps/web/src/components/leatest-change.tsx create mode 100644 apps/web/src/components/logo.tsx create mode 100644 apps/web/src/components/marketing-chrome.tsx create mode 100644 apps/web/src/components/mode-toggle.tsx create mode 100644 apps/web/src/components/nav-user.tsx create mode 100644 apps/web/src/components/theme-provider.tsx create mode 100644 apps/web/src/components/ui/avatar.tsx create mode 100644 apps/web/src/components/ui/breadcrumb.tsx create mode 100644 apps/web/src/components/ui/button.tsx create mode 100644 apps/web/src/components/ui/dropdown-menu.tsx create mode 100644 apps/web/src/components/ui/input.tsx create mode 100644 apps/web/src/components/ui/kbd.tsx create mode 100644 apps/web/src/components/ui/separator.tsx create mode 100644 apps/web/src/components/ui/sheet.tsx create mode 100644 apps/web/src/components/ui/sidebar.tsx create mode 100644 apps/web/src/components/ui/skeleton.tsx create mode 100644 apps/web/src/components/ui/tooltip.tsx create mode 100644 apps/web/src/hooks/use-mobile.ts create mode 100644 assets/header.svg create mode 100644 scripts/update-homebrew-tap.ts create mode 100644 tests/env.test.ts create mode 100644 tests/install-detection.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7141f1b..fc5a14e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -150,3 +150,52 @@ jobs: with: tag: ${{ needs.create-release.outputs.tag }} secrets: inherit + + update-homebrew-tap: + needs: [create-release, build] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Require tap push token + env: + TAP_TOKEN: ${{ secrets.RELEASE_PAT }} + run: | + if [ -z "$TAP_TOKEN" ]; then + echo "Missing secret RELEASE_PAT" + exit 1 + fi + - name: Checkout hack + uses: actions/checkout@v4 + - name: Checkout tap repo + uses: actions/checkout@v4 + with: + repository: hack-dance/homebrew-tap + token: ${{ secrets.RELEASE_PAT }} + ref: main + path: homebrew-tap + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.3.9" + - name: Render formula + env: + GH_TOKEN: ${{ github.token }} + run: | + bun run scripts/update-homebrew-tap.ts \ + --tag="${{ needs.create-release.outputs.tag }}" \ + --version="${{ needs.create-release.outputs.version }}" \ + --tap-dir=homebrew-tap + - name: Commit and push tap update + working-directory: homebrew-tap + run: | + set -euo pipefail + git add Formula/hack.rb + if git diff --cached --quiet; then + echo "No Homebrew tap changes to push." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "build(hack): update formula to ${{ needs.create-release.outputs.tag }}" + git push origin HEAD:main diff --git a/.gitignore b/.gitignore index 91218fd5..5a08cf5b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,9 +28,16 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json *.app-key.pem *.p8 hackdance.*.private-key.pem +.hack/hack.env.default.yaml .hack.secret.key +.hack/.env +.hack/.env.state.json .hack-secrets-file.key .hack-secrets.enc.json +.hack/linear +.hack/_linear +.hack/internal +.hack/.env.state.json # caches .eslintcache diff --git a/README.md b/README.md index 7e9dca5e..8ae8a6a2 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,74 @@ -# Hack - -```text - █████ █████ █████████ █████████ █████ ████ -░░███ ░░███ ███░░░░░███ ███░░░░░███░░███ ███░ - ░███ ░███ ░███ ░███ ███ ░░░ ░███ ███ - ░███████████ ░███████████ ░███ ░███████ - ░███░░░░░███ ░███░░░░░███ ░███ ░███░░███ - ░███ ░███ ░███ ░███ ░░███ ███ ░███ ░░███ - █████ █████ █████ █████ ░░█████████ █████ ░░████ -░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░░░░░ ░░░░░ ░░░░ -``` - -Hack is a local-first platform for developers and agents who need to run more than one project, branch, or workflow at once. - -It makes three promises: **run projects in parallel**, **normalize all your tickets**, and **manage project env safely**. - -When local dev breaks down, it usually breaks down in the same places: ports collide, work gets split across too many ticket systems, and env setup turns into tribal knowledge. Hack is the opinionated layer that makes those problems boring again. - -Sessions and integrations support those promises directly: sessions keep terminal work durable, and integrations let agents, ticket systems, and remote surfaces plug into the same project model. - -## The Three Core Promises - -### 1. Run projects in parallel - -Hack lets each repo or branch run as its own isolated local environment instead of fighting over `localhost`, shared Docker networks, or ad-hoc port maps. +

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

+ +Hack is environment orchestration for software projects. + +For parallel work across agents, teams, and machines. +Portable environments, routed hostnames, TLS, ticket sync, and remote execution. + +It lets agents and teams run parallel work across projects, branches, local machines, and remote hosts without splitting envs, routing, tickets, and execution across separate systems. + +Hack keeps portable envs and secrets, routed hostnames and TLS, ticket workflows, durable sessions, and remote execution inside one project model. + +Parallel software work usually fails in the same places: ports collide, branches stomp on each other, env setup turns into tribal knowledge, ticket state fragments, and remote runs stop behaving like local ones. Hack makes those failures boring. + +## Core Capabilities + +### 1. Parallel environments with stable routing + +Hack lets each repo or branch run as its own isolated local environment with stable routing instead of fighting over `localhost`, shared Docker networks, or ad-hoc port maps. - Run multiple repos at the same time. - Run multiple branches or worktrees of the same repo at the same time. @@ -39,7 +84,7 @@ hack open hack logs --pretty ``` -### 2. Normalize all your tickets +### 2. Ticket workflows across git and Linear Hack keeps a local-first, git-backed ticket substrate in your repo, then lets external systems like Linear sync into that substrate instead of forcing your workflow to live in five different places. @@ -63,7 +108,7 @@ Docs: - [Tickets guide](docs/guides/tickets.md) - [Linear integration architecture](docs/guides/linear-integration-architecture.md) -### 3. Manage project env safely +### 3. Portable envs and secrets Hack separates shareable env structure from secret values so teams and agents can understand what a project needs without leaking sensitive material into git. @@ -102,7 +147,14 @@ Docs: ### 1. Install Hack -CLI only: +Recommended: Homebrew + +```bash +brew tap hack-dance/tap +brew install hack-dance/tap/hack +``` + +Release installer script: ```bash curl -fsSL \ @@ -120,7 +172,7 @@ curl -fsSL \ This slim install path skips `hack global install`, Caddy/CoreDNS, and Loki/Grafana. -CLI + desktop app: +Beta macOS app and bundled CLI: - Download the latest DMG from [GitHub Releases](https://github.com/hack-dance/hack/releases/latest) - Install the CLI from the DMG diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css deleted file mode 100644 index 47155d04..00000000 --- a/apps/web/app/globals.css +++ /dev/null @@ -1,178 +0,0 @@ -@import "tailwindcss"; - -:root { - color-scheme: dark; -} - -* { - box-sizing: border-box; -} - -html { - min-height: 100%; - scroll-behavior: smooth; -} - -body { - min-height: 100vh; - margin: 0; - background: - radial-gradient(circle at top, rgba(56, 189, 248, 0.18), transparent 32%), - linear-gradient(180deg, #020617 0%, #0f172a 100%); - color: #f8fafc; - font-family: - "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, - "Segoe UI", sans-serif; - line-height: 1.5; -} - -a { - color: inherit; - text-decoration: none; -} - -@media (prefers-reduced-motion: reduce) { - html:focus-within { - scroll-behavior: auto; - } - - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - scroll-behavior: auto !important; - transition-duration: 0.01ms !important; - } -} - -.auth-page { - min-height: 100vh; - display: grid; - place-items: center; - padding: 2rem 1rem; -} - -.auth-card { - width: min(36rem, 100%); - display: grid; - gap: 1rem; - padding: 1.5rem; - border: 1px solid rgba(148, 163, 184, 0.26); - border-radius: 1.5rem; - background: rgba(15, 23, 42, 0.88); - box-shadow: 0 24px 60px rgba(2, 6, 23, 0.32); - backdrop-filter: blur(14px); -} - -.auth-eyebrow { - margin: 0; - font-size: 0.85rem; - letter-spacing: 0.12em; - text-transform: uppercase; - color: rgba(191, 219, 254, 0.86); -} - -.auth-card h1 { - margin: 0; - font-size: clamp(2rem, 5vw, 2.6rem); - line-height: 1.05; -} - -.auth-copy, -.auth-panel p, -.auth-status { - margin: 0; - color: rgba(226, 232, 240, 0.76); - line-height: 1.6; -} - -.auth-panel { - display: grid; - gap: 0.5rem; - padding: 1rem; - border-radius: 1rem; - border: 1px solid rgba(148, 163, 184, 0.18); - background: rgba(15, 23, 42, 0.68); -} - -.auth-panel h2 { - margin: 0; - font-size: 1rem; - color: #f8fafc; -} - -.auth-panel-muted { - background: rgba(15, 23, 42, 0.54); -} - -.auth-panel-info { - background: rgba(14, 116, 144, 0.26); - border-color: rgba(56, 189, 248, 0.34); -} - -.auth-panel-success { - background: rgba(21, 128, 61, 0.22); - border-color: rgba(74, 222, 128, 0.34); -} - -.auth-panel-danger { - background: rgba(153, 27, 27, 0.26); - border-color: rgba(248, 113, 113, 0.34); -} - -.auth-actions { - display: grid; - gap: 0.75rem; -} - -.auth-button { - appearance: none; - border: 1px solid transparent; - border-radius: 999px; - padding: 0.9rem 1.1rem; - font: inherit; - font-weight: 600; - background: #38bdf8; - color: #020617; - cursor: pointer; -} - -.auth-button:hover:not(:disabled), -.auth-link:hover { - transform: translateY(-1px); -} - -.auth-button:disabled { - opacity: 0.72; - cursor: progress; -} - -.auth-link { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 2.5rem; - padding: 0.65rem 0.9rem; - border-radius: 999px; - border: 1px solid rgba(148, 163, 184, 0.22); - background: rgba(15, 23, 42, 0.8); - text-decoration: none; - font-weight: 600; -} - -.auth-nav { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; -} - -.auth-status { - min-height: 1.5rem; -} - -.auth-button:focus-visible, -.auth-link:focus-visible { - outline: 3px solid rgba(56, 189, 248, 0.45); - outline-offset: 2px; -} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx deleted file mode 100644 index d4a12484..00000000 --- a/apps/web/app/layout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { ReactNode } from "react"; - -import "./globals.css"; - -import { appMetadata } from "@/src/lib/control-plane-shell"; - -export const metadata = appMetadata; - -export default function RootLayout({ - children, -}: { - readonly children: ReactNode; -}) { - return ( - - {children} - - ); -} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx deleted file mode 100644 index f41a15e3..00000000 --- a/apps/web/app/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import AccountShellPage from "@/src/components/account-shell-page"; - -type SearchParams = Promise>; - -export default function HomePage(input: { - readonly searchParams?: SearchParams; -}) { - return ( - - ); -} diff --git a/apps/web/components.json b/apps/web/components.json index 529467fa..58f8ce84 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -1,17 +1,27 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", + "style": "radix-luma", "rsc": true, "tsx": true, "tailwind": { "config": "", - "css": "app/globals.css", + "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, + "iconLibrary": "hugeicons", + "rtl": false, "aliases": { - "components": "@/src/components", - "utils": "@/src/lib/utils" + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "inverted-translucent", + "menuAccent": "subtle", + "registries": { + "@efferd": "https://efferd.com/r/{style}/{name}.json" } } diff --git a/apps/web/package.json b/apps/web/package.json index e7d1181d..24297511 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,15 +13,20 @@ }, "dependencies": { "@hack/auth-contract": "workspace:*", + "@hugeicons/core-free-icons": "^4.1.1", + "@hugeicons/react": "^1.1.6", "@radix-ui/react-slot": "^1.2.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^0.542.0", + "lucide-react": "^1.7.0", "motion": "^12.23.24", "next": "16.2.1", + "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", "react": "19.2.4", "react-dom": "19.2.4", - "tailwind-merge": "^3.3.1" + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", @@ -30,6 +35,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "linkedom": "^0.18.12", + "shadcn": "^4.1.2", "tailwindcss": "^4.1.18", "typescript": "^5" } diff --git a/apps/web/src/app/account/layout.tsx b/apps/web/src/app/account/layout.tsx new file mode 100644 index 00000000..2ff34575 --- /dev/null +++ b/apps/web/src/app/account/layout.tsx @@ -0,0 +1,9 @@ +import { AppShell } from "@/components/app-shell"; + +export default function AccountLayout({ + children, +}: { + readonly children: React.ReactNode; +}) { + return {children}; +} diff --git a/apps/web/app/account/page.tsx b/apps/web/src/app/account/page.tsx similarity index 77% rename from apps/web/app/account/page.tsx rename to apps/web/src/app/account/page.tsx index e8fcf384..d4cca07a 100644 --- a/apps/web/app/account/page.tsx +++ b/apps/web/src/app/account/page.tsx @@ -1,7 +1,7 @@ import { Suspense } from "react"; -import AccountShellLoading from "@/src/components/account-shell-loading"; -import AccountShellPage from "@/src/components/account-shell-page"; +import AccountShellLoading from "@/components/account-shell-loading"; +import AccountShellPage from "@/components/account-shell-page"; type SearchParams = Promise>; diff --git a/apps/web/app/api/auth/flows/[flowId]/route.ts b/apps/web/src/app/api/auth/flows/[flowId]/route.ts similarity index 95% rename from apps/web/app/api/auth/flows/[flowId]/route.ts rename to apps/web/src/app/api/auth/flows/[flowId]/route.ts index fa0ab82a..fde69cf7 100644 --- a/apps/web/app/api/auth/flows/[flowId]/route.ts +++ b/apps/web/src/app/api/auth/flows/[flowId]/route.ts @@ -1,6 +1,6 @@ import { type NextRequest, NextResponse } from "next/server"; -import { getWebAuthConfig } from "@/src/lib/auth-config"; +import { getWebAuthConfig } from "@/lib/auth-config"; export async function GET( request: NextRequest, diff --git a/apps/web/app/api/auth/providers/route.ts b/apps/web/src/app/api/auth/providers/route.ts similarity index 91% rename from apps/web/app/api/auth/providers/route.ts rename to apps/web/src/app/api/auth/providers/route.ts index 29cbd465..72f9b350 100644 --- a/apps/web/app/api/auth/providers/route.ts +++ b/apps/web/src/app/api/auth/providers/route.ts @@ -1,6 +1,6 @@ import { type NextRequest, NextResponse } from "next/server"; -import { getWebAuthConfig } from "@/src/lib/auth-config"; +import { getWebAuthConfig } from "@/lib/auth-config"; export async function GET(_request: NextRequest) { const config = getWebAuthConfig(); diff --git a/apps/web/app/api/auth/social/route.ts b/apps/web/src/app/api/auth/social/route.ts similarity index 96% rename from apps/web/app/api/auth/social/route.ts rename to apps/web/src/app/api/auth/social/route.ts index 3962f7d8..e7dd7ce7 100644 --- a/apps/web/app/api/auth/social/route.ts +++ b/apps/web/src/app/api/auth/social/route.ts @@ -1,10 +1,10 @@ import { type NextRequest, NextResponse } from "next/server"; -import { getAuthoritativeWebAuthConfig } from "@/src/lib/auth-config"; +import { getAuthoritativeWebAuthConfig } from "@/lib/auth-config"; import { buildBrokerAccountBridgeUrl, normalizeAppReturnUrl, -} from "@/src/lib/auth-handoff"; +} from "@/lib/auth-handoff"; export async function POST(request: NextRequest) { const body = (await readJsonBody(request)) as { diff --git a/apps/web/app/api/control-plane/invitations/[inviteId]/accept/route.ts b/apps/web/src/app/api/control-plane/invitations/[inviteId]/accept/route.ts similarity index 90% rename from apps/web/app/api/control-plane/invitations/[inviteId]/accept/route.ts rename to apps/web/src/app/api/control-plane/invitations/[inviteId]/accept/route.ts index 2fb99e8a..bcf7c276 100644 --- a/apps/web/app/api/control-plane/invitations/[inviteId]/accept/route.ts +++ b/apps/web/src/app/api/control-plane/invitations/[inviteId]/accept/route.ts @@ -6,9 +6,9 @@ import { normalizeAccountRedirectPath, readAccountControlPlaneFormValue, readBrokerSessionTokenFromRequest, -} from "@/src/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/src/lib/account-shell"; -import { getWebAuthConfig } from "@/src/lib/auth-config"; +} from "@/lib/account-control-plane"; +import { buildAccountShellSignInHref } from "@/lib/account-shell"; +import { getWebAuthConfig } from "@/lib/auth-config"; export async function POST( request: NextRequest, diff --git a/apps/web/app/api/control-plane/invitations/[inviteId]/decline/route.ts b/apps/web/src/app/api/control-plane/invitations/[inviteId]/decline/route.ts similarity index 90% rename from apps/web/app/api/control-plane/invitations/[inviteId]/decline/route.ts rename to apps/web/src/app/api/control-plane/invitations/[inviteId]/decline/route.ts index 71b97af7..a1555be0 100644 --- a/apps/web/app/api/control-plane/invitations/[inviteId]/decline/route.ts +++ b/apps/web/src/app/api/control-plane/invitations/[inviteId]/decline/route.ts @@ -6,9 +6,9 @@ import { normalizeAccountRedirectPath, readAccountControlPlaneFormValue, readBrokerSessionTokenFromRequest, -} from "@/src/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/src/lib/account-shell"; -import { getWebAuthConfig } from "@/src/lib/auth-config"; +} from "@/lib/account-control-plane"; +import { buildAccountShellSignInHref } from "@/lib/account-shell"; +import { getWebAuthConfig } from "@/lib/auth-config"; export async function POST( request: NextRequest, diff --git a/apps/web/app/api/control-plane/orgs/[org]/members/invite/route.ts b/apps/web/src/app/api/control-plane/orgs/[org]/members/invite/route.ts similarity index 92% rename from apps/web/app/api/control-plane/orgs/[org]/members/invite/route.ts rename to apps/web/src/app/api/control-plane/orgs/[org]/members/invite/route.ts index c9511e2f..96d7e7b6 100644 --- a/apps/web/app/api/control-plane/orgs/[org]/members/invite/route.ts +++ b/apps/web/src/app/api/control-plane/orgs/[org]/members/invite/route.ts @@ -6,9 +6,9 @@ import { normalizeAccountRedirectPath, readAccountControlPlaneFormValue, readBrokerSessionTokenFromRequest, -} from "@/src/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/src/lib/account-shell"; -import { getWebAuthConfig } from "@/src/lib/auth-config"; +} from "@/lib/account-control-plane"; +import { buildAccountShellSignInHref } from "@/lib/account-shell"; +import { getWebAuthConfig } from "@/lib/auth-config"; export async function POST( request: NextRequest, diff --git a/apps/web/app/api/control-plane/orgs/[org]/members/remove/route.ts b/apps/web/src/app/api/control-plane/orgs/[org]/members/remove/route.ts similarity index 92% rename from apps/web/app/api/control-plane/orgs/[org]/members/remove/route.ts rename to apps/web/src/app/api/control-plane/orgs/[org]/members/remove/route.ts index bf2cc8e9..43f91122 100644 --- a/apps/web/app/api/control-plane/orgs/[org]/members/remove/route.ts +++ b/apps/web/src/app/api/control-plane/orgs/[org]/members/remove/route.ts @@ -6,9 +6,9 @@ import { normalizeAccountRedirectPath, readAccountControlPlaneFormValue, readBrokerSessionTokenFromRequest, -} from "@/src/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/src/lib/account-shell"; -import { getWebAuthConfig } from "@/src/lib/auth-config"; +} from "@/lib/account-control-plane"; +import { buildAccountShellSignInHref } from "@/lib/account-shell"; +import { getWebAuthConfig } from "@/lib/auth-config"; export async function POST( request: NextRequest, diff --git a/apps/web/app/api/control-plane/orgs/route.ts b/apps/web/src/app/api/control-plane/orgs/route.ts similarity index 93% rename from apps/web/app/api/control-plane/orgs/route.ts rename to apps/web/src/app/api/control-plane/orgs/route.ts index a87e3f1f..ae3f585c 100644 --- a/apps/web/app/api/control-plane/orgs/route.ts +++ b/apps/web/src/app/api/control-plane/orgs/route.ts @@ -6,9 +6,9 @@ import { normalizeAccountRedirectPath, readAccountControlPlaneFormValue, readBrokerSessionTokenFromRequest, -} from "@/src/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/src/lib/account-shell"; -import { getWebAuthConfig } from "@/src/lib/auth-config"; +} from "@/lib/account-control-plane"; +import { buildAccountShellSignInHref } from "@/lib/account-shell"; +import { getWebAuthConfig } from "@/lib/auth-config"; type CreateOrganizationResponse = { readonly organization?: { diff --git a/apps/web/app/api/control-plane/projects/[project]/access/grant/route.ts b/apps/web/src/app/api/control-plane/projects/[project]/access/grant/route.ts similarity index 95% rename from apps/web/app/api/control-plane/projects/[project]/access/grant/route.ts rename to apps/web/src/app/api/control-plane/projects/[project]/access/grant/route.ts index a8f1f5e9..730593dc 100644 --- a/apps/web/app/api/control-plane/projects/[project]/access/grant/route.ts +++ b/apps/web/src/app/api/control-plane/projects/[project]/access/grant/route.ts @@ -6,9 +6,9 @@ import { normalizeAccountRedirectPath, readAccountControlPlaneFormValue, readBrokerSessionTokenFromRequest, -} from "@/src/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/src/lib/account-shell"; -import { getWebAuthConfig } from "@/src/lib/auth-config"; +} from "@/lib/account-control-plane"; +import { buildAccountShellSignInHref } from "@/lib/account-shell"; +import { getWebAuthConfig } from "@/lib/auth-config"; type ProjectAccessResponse = { readonly error?: string | null; diff --git a/apps/web/app/api/control-plane/projects/[project]/access/revoke/route.ts b/apps/web/src/app/api/control-plane/projects/[project]/access/revoke/route.ts similarity index 94% rename from apps/web/app/api/control-plane/projects/[project]/access/revoke/route.ts rename to apps/web/src/app/api/control-plane/projects/[project]/access/revoke/route.ts index c09fec2e..b3af02ff 100644 --- a/apps/web/app/api/control-plane/projects/[project]/access/revoke/route.ts +++ b/apps/web/src/app/api/control-plane/projects/[project]/access/revoke/route.ts @@ -6,9 +6,9 @@ import { normalizeAccountRedirectPath, readAccountControlPlaneFormValue, readBrokerSessionTokenFromRequest, -} from "@/src/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/src/lib/account-shell"; -import { getWebAuthConfig } from "@/src/lib/auth-config"; +} from "@/lib/account-control-plane"; +import { buildAccountShellSignInHref } from "@/lib/account-shell"; +import { getWebAuthConfig } from "@/lib/auth-config"; type ProjectAccessResponse = { readonly error?: string | null; diff --git a/apps/web/app/api/control-plane/projects/route.ts b/apps/web/src/app/api/control-plane/projects/route.ts similarity index 95% rename from apps/web/app/api/control-plane/projects/route.ts rename to apps/web/src/app/api/control-plane/projects/route.ts index d2be1cd5..1d760a8c 100644 --- a/apps/web/app/api/control-plane/projects/route.ts +++ b/apps/web/src/app/api/control-plane/projects/route.ts @@ -6,9 +6,9 @@ import { normalizeAccountRedirectPath, readAccountControlPlaneFormValue, readBrokerSessionTokenFromRequest, -} from "@/src/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/src/lib/account-shell"; -import { getWebAuthConfig } from "@/src/lib/auth-config"; +} from "@/lib/account-control-plane"; +import { buildAccountShellSignInHref } from "@/lib/account-shell"; +import { getWebAuthConfig } from "@/lib/auth-config"; type RegisterProjectResponse = { readonly project?: { diff --git a/apps/web/app/api/control-plane/teams/[team]/members/invite/route.ts b/apps/web/src/app/api/control-plane/teams/[team]/members/invite/route.ts similarity index 93% rename from apps/web/app/api/control-plane/teams/[team]/members/invite/route.ts rename to apps/web/src/app/api/control-plane/teams/[team]/members/invite/route.ts index 893c1e4a..3e60e236 100644 --- a/apps/web/app/api/control-plane/teams/[team]/members/invite/route.ts +++ b/apps/web/src/app/api/control-plane/teams/[team]/members/invite/route.ts @@ -6,9 +6,9 @@ import { normalizeAccountRedirectPath, readAccountControlPlaneFormValue, readBrokerSessionTokenFromRequest, -} from "@/src/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/src/lib/account-shell"; -import { getWebAuthConfig } from "@/src/lib/auth-config"; +} from "@/lib/account-control-plane"; +import { buildAccountShellSignInHref } from "@/lib/account-shell"; +import { getWebAuthConfig } from "@/lib/auth-config"; type ErrorResponse = { readonly error?: string | null; diff --git a/apps/web/app/api/control-plane/teams/[team]/members/remove/route.ts b/apps/web/src/app/api/control-plane/teams/[team]/members/remove/route.ts similarity index 92% rename from apps/web/app/api/control-plane/teams/[team]/members/remove/route.ts rename to apps/web/src/app/api/control-plane/teams/[team]/members/remove/route.ts index b38c2ead..4767b8f3 100644 --- a/apps/web/app/api/control-plane/teams/[team]/members/remove/route.ts +++ b/apps/web/src/app/api/control-plane/teams/[team]/members/remove/route.ts @@ -6,9 +6,9 @@ import { normalizeAccountRedirectPath, readAccountControlPlaneFormValue, readBrokerSessionTokenFromRequest, -} from "@/src/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/src/lib/account-shell"; -import { getWebAuthConfig } from "@/src/lib/auth-config"; +} from "@/lib/account-control-plane"; +import { buildAccountShellSignInHref } from "@/lib/account-shell"; +import { getWebAuthConfig } from "@/lib/auth-config"; export async function POST( request: NextRequest, diff --git a/apps/web/app/api/control-plane/teams/route.ts b/apps/web/src/app/api/control-plane/teams/route.ts similarity index 93% rename from apps/web/app/api/control-plane/teams/route.ts rename to apps/web/src/app/api/control-plane/teams/route.ts index 0eb6aa10..edb2bdf8 100644 --- a/apps/web/app/api/control-plane/teams/route.ts +++ b/apps/web/src/app/api/control-plane/teams/route.ts @@ -6,9 +6,9 @@ import { normalizeAccountRedirectPath, readAccountControlPlaneFormValue, readBrokerSessionTokenFromRequest, -} from "@/src/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/src/lib/account-shell"; -import { getWebAuthConfig } from "@/src/lib/auth-config"; +} from "@/lib/account-control-plane"; +import { buildAccountShellSignInHref } from "@/lib/account-shell"; +import { getWebAuthConfig } from "@/lib/auth-config"; type CreateTeamResponse = { readonly team?: { diff --git a/apps/web/app/auth/account/page.tsx b/apps/web/src/app/auth/account/page.tsx similarity index 84% rename from apps/web/app/auth/account/page.tsx rename to apps/web/src/app/auth/account/page.tsx index 2aa90973..47485644 100644 --- a/apps/web/app/auth/account/page.tsx +++ b/apps/web/src/app/auth/account/page.tsx @@ -1,6 +1,6 @@ -import { AuthEntrypoint } from "@/src/components/auth-entrypoint"; -import { getAuthoritativeWebAuthConfig } from "@/src/lib/auth-config"; -import { hasAuthenticatedBrowserSession } from "@/src/lib/browser-auth-session"; +import { AuthEntrypoint } from "@/components/auth-entrypoint"; +import { getAuthoritativeWebAuthConfig } from "@/lib/auth-config"; +import { hasAuthenticatedBrowserSession } from "@/lib/browser-auth-session"; type SearchParams = Promise>; diff --git a/apps/web/app/auth/page.tsx b/apps/web/src/app/auth/page.tsx similarity index 86% rename from apps/web/app/auth/page.tsx rename to apps/web/src/app/auth/page.tsx index 5f8941fc..45547ac6 100644 --- a/apps/web/app/auth/page.tsx +++ b/apps/web/src/app/auth/page.tsx @@ -1,5 +1,5 @@ -import { AuthEntrypoint } from "@/src/components/auth-entrypoint"; -import { getAuthoritativeWebAuthConfig } from "@/src/lib/auth-config"; +import { AuthEntrypoint } from "@/components/auth-entrypoint"; +import { getAuthoritativeWebAuthConfig } from "@/lib/auth-config"; type SearchParams = Promise>; diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 00000000..88ee6a8b --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1,171 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + color-scheme: light; + --radius: 0.625rem; + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.488 0.243 264.376); + --primary-foreground: oklch(0.97 0.014 254.604); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.546 0.245 262.881); + --sidebar-primary-foreground: oklch(0.97 0.014 254.604); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --chart-1: oklch(0.845 0.143 164.978); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.596 0.145 163.225); + --chart-4: oklch(0.508 0.118 165.612); + --chart-5: oklch(0.432 0.095 166.913); +} + +.dark { + color-scheme: dark; + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.424 0.199 265.638); + --primary-foreground: oklch(0.97 0.014 254.604); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.623 0.214 259.815); + --sidebar-primary-foreground: oklch(0.97 0.014 254.604); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + --chart-1: oklch(0.845 0.143 164.978); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.596 0.145 163.225); + --chart-4: oklch(0.508 0.118 165.612); + --chart-5: oklch(0.432 0.095 166.913); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +* { + box-sizing: border-box; +} + +html { + min-height: 100%; + scroll-behavior: smooth; +} + +body { + min-height: 100vh; + margin: 0; + font-family: + ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + color: inherit; + text-decoration: none; +} + +@media (prefers-reduced-motion: reduce) { + html:focus-within { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 00000000..022e4a87 --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; + +import "./globals.css"; + +import { Geist } from "next/font/google"; +import { Providers } from "@/app/providers"; +import { appMetadata } from "@/lib/control-plane-shell"; +import { cn } from "@/lib/utils"; + +const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }); + +export const metadata = appMetadata; + +export default function RootLayout({ + children, +}: { + readonly children: ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx new file mode 100644 index 00000000..b8a63720 --- /dev/null +++ b/apps/web/src/app/page.tsx @@ -0,0 +1,13 @@ +import { BigLogo } from "@/components/big-logo"; +import { MarketingChrome } from "@/components/marketing-chrome"; + +export default function HomePage() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx new file mode 100644 index 00000000..0f359d8e --- /dev/null +++ b/apps/web/src/app/providers.tsx @@ -0,0 +1,14 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { ThemeProvider } from "@/components/theme-provider"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +export function Providers({ children }: { readonly children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/account-control-plane-sections.tsx b/apps/web/src/components/account-control-plane-sections.tsx index 2c7d0fee..03835f4b 100644 --- a/apps/web/src/components/account-control-plane-sections.tsx +++ b/apps/web/src/components/account-control-plane-sections.tsx @@ -1,12 +1,12 @@ import type { ReactNode } from "react"; -import LinearManagementSection from "@/src/components/linear-management-section"; -import type { AccountControlPlaneFeedback } from "@/src/lib/account-control-plane"; -import { buildAccountControlPlanePath } from "@/src/lib/account-control-plane"; -import type { AccountShellContext } from "@/src/lib/account-shell"; -import type { EnvManagementState } from "@/src/lib/env-management"; -import type { GitHubManagementState } from "@/src/lib/github-management"; -import type { LinearManagementState } from "@/src/lib/linear-management"; -import { cn } from "@/src/lib/utils"; +import LinearManagementSection from "@/components/linear-management-section"; +import type { AccountControlPlaneFeedback } from "@/lib/account-control-plane"; +import { buildAccountControlPlanePath } from "@/lib/account-control-plane"; +import type { AccountShellContext } from "@/lib/account-shell"; +import type { EnvManagementState } from "@/lib/env-management"; +import type { GitHubManagementState } from "@/lib/github-management"; +import type { LinearManagementState } from "@/lib/linear-management"; +import { cn } from "@/lib/utils"; const sectionSurfaceClassName = cn( "rounded-3xl border border-white/10 bg-white/[0.04] shadow-[0_24px_80px_rgba(15,23,42,0.24)]", diff --git a/apps/web/src/components/account-shell-loading.tsx b/apps/web/src/components/account-shell-loading.tsx index 178d68ef..7bf1c41a 100644 --- a/apps/web/src/components/account-shell-loading.tsx +++ b/apps/web/src/components/account-shell-loading.tsx @@ -1,7 +1,7 @@ import { Compass } from "lucide-react"; -import { shellSummary, shellTitle } from "@/src/lib/control-plane-shell"; -import { cn } from "@/src/lib/utils"; +import { shellSummary, shellTitle } from "@/lib/control-plane-shell"; +import { cn } from "@/lib/utils"; const loadingSurfaceClassName = cn( "rounded-3xl border border-white/10 bg-white/[0.04] shadow-[0_24px_80px_rgba(15,23,42,0.24)]", diff --git a/apps/web/src/components/account-shell-page.tsx b/apps/web/src/components/account-shell-page.tsx index 6d851ee4..2b224619 100644 --- a/apps/web/src/components/account-shell-page.tsx +++ b/apps/web/src/components/account-shell-page.tsx @@ -1,16 +1,16 @@ -import ControlPlaneShell from "@/src/components/control-plane-shell"; +import ControlPlaneShell from "@/components/control-plane-shell"; import { buildAccountControlPlanePath, resolveAccountControlPlaneFeedback, -} from "@/src/lib/account-control-plane"; +} from "@/lib/account-control-plane"; import { buildAccountShellSignInHref, getAccountShellContext, -} from "@/src/lib/account-shell"; -import { resolveBrowserSharedProjectScope } from "@/src/lib/browser-shared-project-scope"; -import { loadEnvManagementState } from "@/src/lib/env-management"; -import { loadGitHubManagementState } from "@/src/lib/github-management"; -import { loadLinearManagementState } from "@/src/lib/linear-management"; +} from "@/lib/account-shell"; +import { resolveBrowserSharedProjectScope } from "@/lib/browser-shared-project-scope"; +import { loadEnvManagementState } from "@/lib/env-management"; +import { loadGitHubManagementState } from "@/lib/github-management"; +import { loadLinearManagementState } from "@/lib/linear-management"; export default async function AccountShellPage(input: { readonly returnToPath: string; diff --git a/apps/web/src/components/app-navbar.tsx b/apps/web/src/components/app-navbar.tsx new file mode 100644 index 00000000..a7a3152f --- /dev/null +++ b/apps/web/src/components/app-navbar.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { CustomSidebarTrigger } from "@/components/custom-sidebar-trigger"; +import { NavUser } from "@/components/nav-user"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, +} from "@/components/ui/breadcrumb"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +export function AppNavbar() { + return ( +
+
+ + + + + + Account + + + +
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx new file mode 100644 index 00000000..6487abac --- /dev/null +++ b/apps/web/src/components/app-shell.tsx @@ -0,0 +1,15 @@ +import { AppNavbar } from "@/components/app-navbar"; +import { AppSidebar } from "@/components/app-sidebar"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; + +export function AppShell({ children }: { children: React.ReactNode }) { + return ( + + + + +
{children}
+
+
+ ); +} diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx new file mode 100644 index 00000000..d4b6f294 --- /dev/null +++ b/apps/web/src/components/app-sidebar.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { + BarChart3Icon, + BookOpenIcon, + BriefcaseIcon, + CreditCardIcon, + HelpCircleIcon, + KeyRoundIcon, + LayoutGridIcon, + PlugIcon, + SettingsIcon, + UsersIcon, +} from "lucide-react"; +import Link from "next/link"; + +import { LatestChange } from "@/components/leatest-change"; +import { Logo } from "@/components/logo"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail, +} from "@/components/ui/sidebar"; +import { cn } from "@/lib/utils"; + +export type SidebarNavItem = { + title: string; + url: string; + icon: React.ReactNode; + isActive?: boolean; +}; + +type SidebarSection = { + label: string; + items: SidebarNavItem[]; +}; + +const navSections: SidebarSection[] = [ + { + label: "Product", + items: [ + { + title: "Dashboard", + url: "/account", + icon: , + isActive: true, + }, + { + title: "Analytics", + url: "/account", + icon: , + }, + { + title: "Projects", + url: "/account", + icon: , + }, + ], + }, + { + label: "Workspace", + items: [ + { + title: "Team", + url: "/account", + icon: , + }, + { + title: "Integrations", + url: "/account", + icon: , + }, + { + title: "API Keys", + url: "/account", + icon: , + }, + ], + }, + { + label: "Administration", + items: [ + { + title: "Settings", + url: "/account", + icon: , + }, + { + title: "Billing", + url: "/account", + icon: , + }, + ], + }, +]; + +const footerNavLinks: SidebarNavItem[] = [ + { + title: "Help Center", + url: "https://github.com/hack-dance/hack", + icon: , + }, + { + title: "Documentation", + url: "https://github.com/hack-dance/hack", + icon: , + }, +]; + +export function AppSidebar() { + return ( + span]:text-foreground/75" + )} + collapsible="icon" + variant="sidebar" + > + + + + + Hack + + + + + {navSections.map((section) => ( + + + {section.label} + + + {section.items.map((item) => ( + + + + {item.icon} + {item.title} + + + + ))} + + + ))} + + + + + {footerNavLinks.map((item) => ( + + + + {item.icon} + {item.title} + + + + ))} + +
+

+ © {new Date().getFullYear()} Hack +

+
+
+ +
+ ); +} diff --git a/apps/web/src/components/auth-entrypoint.tsx b/apps/web/src/components/auth-entrypoint.tsx index c9db324a..b79968ba 100644 --- a/apps/web/src/components/auth-entrypoint.tsx +++ b/apps/web/src/components/auth-entrypoint.tsx @@ -6,6 +6,9 @@ import type { } from "@hack/auth-contract"; import { useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + import { normalizeAppReturnUrl, resolveInitialAuthFlowKind, @@ -271,16 +274,35 @@ export function AuthEntrypoint({ }; return ( -
-
-

Hack auth

-

{summary.title}

-

{summary.body}

+
+
+

+ Hack auth +

+

+ {summary.title} +

+

+ {summary.body} +

{hasFlowContext ? ( -
-

Linked browser handoff

-

+

+

+ Linked browser handoff +

+

This tab is linked to a Hack client flow. Complete sign-in here to let the broker finish session establishment for the originating CLI or deep link. @@ -289,32 +311,40 @@ export function AuthEntrypoint({ ) : null} {mode === "account" ? ( -

-

{flowStatus.title}

-

{flowStatus.body}

+
+

+ {flowStatus.title} +

+

+ {flowStatus.body} +

{flowStatus.href ? ( - - {flowStatus.label} - + ) : null}
) : null} {resolvedProviders.length > 0 ? ( -
-

+
+

{mode === "account" ? "Continue with a provider" : "Choose a provider"}

-
+
{resolvedProviders.map((provider) => { const loading = actionState.kind === "loading" && actionState.providerId === provider.id; return ( - + ); })}
) : ( -
-

Sign-in is unavailable

-

+

+

+ Sign-in is unavailable +

+

No shared social providers are configured for this environment yet. Configure the broker providers and refresh this page.

)} -
- ) : null} - - {resolvedProviders.length > 0 ? ( -
-

- {mode === "account" - ? "Continue with a provider" - : "Choose a provider"} -

-
- {resolvedProviders.map((provider) => { - const loading = - actionState.kind === "loading" && - actionState.providerId === provider.id; - return ( - - ); - })} -
-
- ) : ( -
-

- Sign-in is unavailable -

-

- No shared social providers are configured for this environment - yet. Configure the broker providers and refresh this page. -

-
- )} - - - -

- {actionState.kind === "error" - ? actionState.message - : flowStatus.statusText} -

-

+ {normalizedRedirect && + !shouldAutoNavigateToReturnUrl({ value: normalizedRedirect }) ? ( + + ) : null} + + +
); } +function resolveProviderGate(input: { + readonly betterAuthSource: "broker" | "fail_closed"; + readonly betterAuthEnabled: boolean; + readonly providerCount: number; +}): { readonly title: string; readonly body: string } | null { + if (input.betterAuthSource === "fail_closed") { + return { + title: "Cannot reach the auth broker", + body: "Start your stack with Hack (for example `hack up`), then open this site using your Hack dev hostname (for example https://hack-cli.hack), not raw localhost, so the app can reach the broker.", + }; + } + if (!input.betterAuthEnabled) { + return { + title: "Better Auth is not active", + body: "The broker is reachable but Better Auth is off. Ensure DATABASE_URL and BETTER_AUTH_SECRET are set for the auth-broker service, then restart it.", + }; + } + if (input.providerCount === 0) { + return { + title: "No OAuth providers", + body: "GitHub client credentials are not configured on the broker. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET (or BETTER_AUTH_GITHUB_*), redeploy the broker, and add your callback URL to the GitHub OAuth app.", + }; + } + return null; +} + function authPanelClassName( tone: "neutral" | "info" | "success" | "danger" | "muted" ): string { @@ -436,6 +481,7 @@ function authPanelClassName( function resolveSummary(input: { readonly mode: "sign-in" | "account"; + readonly variant: "sign-in" | "sign-up"; readonly hasFlowContext: boolean; readonly authBrokerBaseUrl: string; }): { readonly title: string; readonly body: string } { @@ -447,21 +493,29 @@ function resolveSummary(input: { : `Use this route to resume broker-backed auth handoff or continue account management via ${input.authBrokerBaseUrl}.`, }; } + if (input.variant === "sign-up") { + return { + title: "Create your Hack account", + body: input.hasFlowContext + ? "This sign-up is linked to a Hack client flow. Continue with GitHub to finish provisioning." + : "Use GitHub to create your account. If you already use Hack, sign in instead.", + }; + } return { title: "Sign in to Hack", body: input.hasFlowContext - ? "This sign-in request came from Hack. Continue with a provider to finish the CLI and browser handoff." - : "Start a shared Hack session in the browser while keeping the broker as the source of truth for auth and session APIs.", + ? "This sign-in request came from Hack. Continue with GitHub to finish the CLI and browser handoff." + : "Sign in with GitHub. Sessions are issued by the Hack auth broker.", }; } -function buildAuthRouteHref(input: { - readonly mode: "sign-in" | "account"; +function buildAuthPageHref(input: { + readonly page: "sign-in" | "sign-up" | "account"; readonly flowId?: string; readonly deviceCode?: string; readonly redirect: string | null; }): string { - const path = input.mode === "sign-in" ? "/auth" : "/auth/account"; + const path = input.page === "account" ? "/auth/account" : "/auth"; const searchParams = new URLSearchParams(); if (input.flowId) { searchParams.set("flowId", input.flowId); @@ -472,6 +526,9 @@ function buildAuthRouteHref(input: { if (input.redirect) { searchParams.set("redirect", input.redirect); } + if (input.page === "sign-up") { + searchParams.set("variant", "sign-up"); + } const query = searchParams.toString(); return query.length > 0 ? `${path}?${query}` : path; } diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx new file mode 100644 index 00000000..06eceacd --- /dev/null +++ b/apps/web/src/components/ui/card.tsx @@ -0,0 +1,100 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 dark:ring-foreground/10 *:[img:first-child]:rounded-t-4xl *:[img:last-child]:rounded-b-4xl", + className + )} + data-size={size} + data-slot="card" + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx new file mode 100644 index 00000000..19590211 --- /dev/null +++ b/apps/web/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Label as LabelPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/apps/web/tests/auth-entrypoints.test.tsx b/apps/web/tests/auth-entrypoints.test.tsx index ace60925..35e8df32 100644 --- a/apps/web/tests/auth-entrypoints.test.tsx +++ b/apps/web/tests/auth-entrypoints.test.tsx @@ -91,7 +91,7 @@ test("account route renders browser handoff status in apps/web", async () => { expect(markup).toContain("Finish your Hack browser handoff"); expect(markup).toContain("Waiting for the broker session"); expect(markup).toContain("Continue with GitHub"); - expect(markup).toContain("Open the broker backend"); + expect(markup).toContain("Auth broker"); expect(markup).toContain( 'href="/auth?flowId=flow-123&deviceCode=device-123&redirect=hack%3A%2F%2Fauth%2Fcomplete"' ); @@ -102,6 +102,8 @@ test("account entrypoint renders a ready return state for browser-owned redirect 0 ? { trustedOrigins: [...contract.trustedOrigins] } : {}), From 87c1ebe4ad0d204f92a59c7a964d5593cb17ddf3 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 2 Apr 2026 15:57:03 -0400 Subject: [PATCH 5/8] feat(web): expand account shell and broker auth flows --- .hack/docker-compose.yml | 12 - .hack/hack.env.remote.yaml | 15 + AGENTS.md | 13 + apps/web/components.json | 2 +- apps/web/package.json | 2 +- .../web/src/app/account/integrations/page.tsx | 154 +++++ apps/web/src/app/account/layout.tsx | 18 +- apps/web/src/app/account/page.tsx | 182 +++++- apps/web/src/app/account/projects/page.tsx | 171 +++++ apps/web/src/app/account/secrets/page.tsx | 104 ++++ apps/web/src/app/account/teams/page.tsx | 139 +++++ apps/web/src/app/account/tickets/page.tsx | 49 ++ .../src/app/api/auth/flows/[flowId]/route.ts | 8 +- apps/web/src/app/api/auth/providers/route.ts | 7 +- apps/web/src/app/api/auth/sign-out/route.ts | 29 + apps/web/src/app/api/auth/social/route.ts | 32 +- apps/web/src/app/auth/account/page.tsx | 8 +- apps/web/src/app/auth/page.tsx | 7 - apps/web/src/app/auth/sign-up/page.tsx | 35 ++ apps/web/src/app/layout.tsx | 29 +- .../web/src/components/account-page-frame.tsx | 91 +++ .../src/components/account-shell-loading.tsx | 5 - apps/web/src/components/app-navbar.tsx | 38 +- apps/web/src/components/app-shell.tsx | 19 +- apps/web/src/components/app-sidebar.tsx | 118 ++-- apps/web/src/components/auth-entrypoint.tsx | 268 ++++---- .../src/components/control-plane-shell.tsx | 5 - apps/web/src/components/github-icon.tsx | 13 + apps/web/src/components/mode-toggle.tsx | 2 +- apps/web/src/components/nav-user.tsx | 105 ++-- .../src/components/organization-switcher.tsx | 125 ++++ apps/web/src/components/theme-provider.tsx | 95 ++- apps/web/src/components/ui/button.tsx | 22 +- apps/web/src/components/ui/card.tsx | 15 +- apps/web/src/lib/account-navigation.ts | 83 +++ apps/web/src/lib/account-shell.ts | 10 +- apps/web/src/lib/auth-config.ts | 73 ++- apps/web/src/lib/auth-handoff.ts | 21 +- apps/web/src/lib/browser-auth-session.ts | 7 +- apps/web/src/lib/server-navigation.ts | 7 + apps/web/src/{app => styles}/globals.css | 104 ++-- apps/web/tests/account-app-shell.test.tsx | 140 +++++ .../account-control-plane-routes.test.ts | 102 +-- .../tests/account-shell-hydration.test.tsx | 19 +- apps/web/tests/auth-config.test.ts | 33 + apps/web/tests/auth-entrypoints.test.tsx | 115 +++- apps/web/tests/auth-handoff.test.ts | 11 +- apps/web/tests/control-plane-shell.test.tsx | 2 +- apps/web/tests/package-manifest.test.ts | 2 +- docs/agent-native-runtime-landscape.md | 589 ------------------ ...nt-native-runtime-provider-capabilities.md | 124 ---- docs/extensions.md | 4 + docs/guides/auth-broker-callbacks.md | 43 ++ packages/auth-contract/src/index.ts | 37 ++ services/auth-broker/src/better-auth.ts | 14 + services/auth-broker/src/config.ts | 30 +- .../src/modules/better-auth/shell-plugin.ts | 434 +++++++++---- .../tests/better-auth-config.test.ts | 13 + services/auth-broker/tests/config.test.ts | 44 +- services/auth-broker/tests/index.test.ts | 29 +- .../auth-broker/tests/session-auth.test.ts | 186 ++++-- 61 files changed, 2771 insertions(+), 1442 deletions(-) create mode 100644 .hack/hack.env.remote.yaml create mode 100644 apps/web/src/app/account/integrations/page.tsx create mode 100644 apps/web/src/app/account/projects/page.tsx create mode 100644 apps/web/src/app/account/secrets/page.tsx create mode 100644 apps/web/src/app/account/teams/page.tsx create mode 100644 apps/web/src/app/account/tickets/page.tsx create mode 100644 apps/web/src/app/api/auth/sign-out/route.ts create mode 100644 apps/web/src/app/auth/sign-up/page.tsx create mode 100644 apps/web/src/components/account-page-frame.tsx create mode 100644 apps/web/src/components/github-icon.tsx create mode 100644 apps/web/src/components/organization-switcher.tsx create mode 100644 apps/web/src/lib/account-navigation.ts create mode 100644 apps/web/src/lib/server-navigation.ts rename apps/web/src/{app => styles}/globals.css (72%) create mode 100644 apps/web/tests/account-app-shell.test.tsx create mode 100644 apps/web/tests/auth-config.test.ts delete mode 100644 docs/agent-native-runtime-landscape.md delete mode 100644 docs/agent-native-runtime-provider-capabilities.md create mode 100644 docs/guides/auth-broker-callbacks.md create mode 100644 services/auth-broker/tests/better-auth-config.test.ts diff --git a/.hack/docker-compose.yml b/.hack/docker-compose.yml index da39a7b5..ed1e7464 100644 --- a/.hack/docker-compose.yml +++ b/.hack/docker-compose.yml @@ -23,12 +23,6 @@ services: environment: CHOKIDAR_USEPOLLING: "true" WATCHPACK_POLLING: "true" - HACK_WEB_APP_BASE_URL: https://hack-cli.hack - HACK_AUTH_BROKER_URL: https://auth.hack-cli.hack - HACK_AUTH_BROKER_INTERNAL_URL: http://auth-broker:8080 - NEXT_PUBLIC_HACK_WEB_APP_BASE_URL: https://hack-cli.hack - NEXT_PUBLIC_HACK_AUTH_BROKER_URL: https://auth.hack-cli.hack - BETTER_AUTH_TRUSTED_ORIGINS: https://hack-cli.hack,https://hack-cli.hack.gy labels: caddy: "hack-cli.hack, hack-cli.hack.gy" caddy.reverse_proxy: "{{upstreams 3000}}" @@ -50,12 +44,6 @@ services: environment: PORT: "8080" HOST: "0.0.0.0" - AUTH_BROKER_PUBLIC_BASE_URL: https://auth.hack-cli.hack - BETTER_AUTH_URL: https://auth.hack-cli.hack - HACK_WEB_APP_BASE_URL: https://hack-cli.hack - BETTER_AUTH_TRUSTED_ORIGINS: https://hack-cli.hack,https://hack-cli.hack.gy - GITHUB_REDIRECT_URI: https://auth.hack-cli.hack/gh/callback - HACK_LINEAR_REDIRECT_URI: https://auth.hack-cli.hack/linear/callback labels: caddy: "auth.hack-cli.hack, auth.hack-cli.hack.gy" caddy.reverse_proxy: "{{upstreams 8080}}" diff --git a/.hack/hack.env.remote.yaml b/.hack/hack.env.remote.yaml new file mode 100644 index 00000000..6a3deeb6 --- /dev/null +++ b/.hack/hack.env.remote.yaml @@ -0,0 +1,15 @@ +version: 1 +environment: remote +secretsprovider: project_key +values: + web: + HACK_AUTH_BROKER_URL: https://auth.hack.broker + NEXT_PUBLIC_HACK_AUTH_BROKER_URL: https://auth.hack.broker + auth-broker: + AUTH_BROKER_PUBLIC_BASE_URL: https://auth.hack.broker + BETTER_AUTH_URL: https://auth.hack.broker + # Broker custom GitHub OAuth callback for Hack-owned flows. + # Better Auth browser social login uses: + # ${BETTER_AUTH_URL}/api/auth/callback/github + GITHUB_REDIRECT_URI: https://auth.hack.broker/gh/callback + HACK_LINEAR_REDIRECT_URI: https://auth.hack.broker/linear/callback diff --git a/AGENTS.md b/AGENTS.md index aae2b66d..254a0543 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -358,3 +358,16 @@ Agent setup (CLI-first): - MCP (no-shell only): `hack setup mcp` - MCP install (explicit): `hack mcp install --all --scope project` + +## Learned User Preferences + +- Prefer Tailwind utilities and shadcn components for auth and similar UI instead of bespoke CSS class stacks such as `.auth-*` when utilities can express the same layout and states. +- After substantive `apps/web` UI changes, verify in a real browser and check the console for runtime warnings. +- When running shadcn CLI init or updates in `apps/web`, merge generated output with existing providers, registries, and project-specific styles rather than overwriting custom shell or auth wiring. + +## Learned Workspace Facts + +- Better Auth runs in `services/auth-broker`, not inside the Next app. Browser GitHub sign-in uses a `redirect_uri` on the **auth broker** host (for example `auth..hack.gy`), not on the primary web app host (`.hack.gy`). +- On the auth broker host this repo may use two GitHub callback paths: Better Auth at `/api/auth/callback/github` and the broker custom flow at `/gh/callback`. The GitHub OAuth app must allow the exact `redirect_uri` emitted in the live authorize request. +- Keep `services/auth-broker` as the auth authority (sessions, provider callbacks, CLI-related flows); treat `apps/web` as browser UX and thin BFF/proxy. See `docs/guides/auth-broker-callbacks.md` for callback and handoff wording. +- `apps/web` theme switching uses a small custom theme context plus layout bootstrap rather than `next-themes` `ThemeProvider`, to avoid Next.js 16 / React client warnings about ` {children} diff --git a/apps/web/src/components/account-page-frame.tsx b/apps/web/src/components/account-page-frame.tsx new file mode 100644 index 00000000..589dfb4f --- /dev/null +++ b/apps/web/src/components/account-page-frame.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from "react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export function AccountPageFrame(input: { + readonly title: string; + readonly description: string; + readonly children: ReactNode; +}) { + return ( +
+
+

+ {input.title} +

+

+ {input.description} +

+
+ {input.children} +
+ ); +} + +export function AccountSectionCard(input: { + readonly title: string; + readonly description?: string; + readonly action?: ReactNode; + readonly children: ReactNode; +}) { + return ( + + +
+
+ {input.title} + {input.description ? ( + {input.description} + ) : null} +
+ {input.action ?
{input.action}
: null} +
+
+ {input.children} +
+ ); +} + +export function AccountEmptyState(input: { + readonly title: string; + readonly body: string; +}) { + return ( +
+

{input.title}

+

{input.body}

+
+ ); +} + +export function AccountStatsGrid(input: { + readonly items: readonly { + readonly label: string; + readonly value: string; + readonly hint?: string; + }[]; +}) { + return ( +
+ {input.items.map((item) => ( +
+
+ {item.label} +
+
+ {item.value} +
+ {item.hint ? ( +

{item.hint}

+ ) : null} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/account-shell-loading.tsx b/apps/web/src/components/account-shell-loading.tsx index 7bf1c41a..91152d4b 100644 --- a/apps/web/src/components/account-shell-loading.tsx +++ b/apps/web/src/components/account-shell-loading.tsx @@ -14,11 +14,6 @@ const loadingPlaceholderClassName = export default function AccountShellLoading() { return (
-
- +
); diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 6487abac..99292570 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -1,14 +1,25 @@ import { AppNavbar } from "@/components/app-navbar"; import { AppSidebar } from "@/components/app-sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import type { AccountShellContext } from "@/lib/account-shell"; -export function AppShell({ children }: { children: React.ReactNode }) { +type AuthenticatedAccount = Extract< + AccountShellContext, + { readonly authenticated: true } +>; + +export function AppShell(input: { + readonly account: AuthenticatedAccount; + readonly children: React.ReactNode; +}) { return ( - + - -
{children}
+ +
+ {input.children} +
); diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 24c0b967..6bb52047 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -1,21 +1,11 @@ "use client"; -import { - BarChart3Icon, - BookOpenIcon, - BriefcaseIcon, - CreditCardIcon, - HelpCircleIcon, - KeyRoundIcon, - LayoutGridIcon, - PlugIcon, - SettingsIcon, - UsersIcon, -} from "lucide-react"; +import { ArrowUpRightIcon } from "lucide-react"; import Link from "next/link"; +import { usePathname } from "next/navigation"; -import { LatestChange } from "@/components/leatest-change"; import { Logo } from "@/components/logo"; +import { OrganizationSwitcher } from "@/components/organization-switcher"; import { Sidebar, SidebarContent, @@ -28,6 +18,8 @@ import { SidebarMenuItem, SidebarRail, } from "@/components/ui/sidebar"; +import { accountNavigationItems } from "@/lib/account-navigation"; +import type { AccountShellContext } from "@/lib/account-shell"; import { cn } from "@/lib/utils"; export type SidebarNavItem = { @@ -42,79 +34,30 @@ type SidebarSection = { items: SidebarNavItem[]; }; -const navSections: SidebarSection[] = [ - { - label: "Product", - items: [ - { - title: "Dashboard", - url: "/account", - icon: , - isActive: true, - }, - { - title: "Analytics", - url: "/account", - icon: , - }, - { - title: "Projects", - url: "/account", - icon: , - }, - ], - }, - { - label: "Workspace", - items: [ - { - title: "Team", - url: "/account", - icon: , - }, - { - title: "Integrations", - url: "/account", - icon: , - }, - { - title: "API Keys", - url: "/account", - icon: , - }, - ], - }, - { - label: "Administration", - items: [ - { - title: "Settings", - url: "/account", - icon: , - }, - { - title: "Billing", - url: "/account", - icon: , - }, - ], - }, -]; +type AuthenticatedAccount = Extract< + AccountShellContext, + { readonly authenticated: true } +>; const footerNavLinks: SidebarNavItem[] = [ { - title: "Help Center", + title: "Documentation", url: "https://github.com/hack-dance/hack", - icon: , + icon: , }, { - title: "Documentation", + title: "GitHub", url: "https://github.com/hack-dance/hack", - icon: , + icon: , }, ]; -export function AppSidebar() { +export function AppSidebar(input: { readonly account: AuthenticatedAccount }) { + const pathname = usePathname(); + const navSections = buildNavSections({ + pathname, + }); + return ( - +
+ +
{footerNavLinks.map((item) => ( @@ -186,3 +131,22 @@ export function AppSidebar() {
); } + +function buildNavSections(input: { + readonly pathname: string; +}): readonly SidebarSection[] { + return [ + { + label: "Workspace", + items: accountNavigationItems.map((item) => ({ + title: item.title, + url: item.href, + icon: , + isActive: + item.href === "/account" + ? input.pathname === "/account" + : input.pathname.startsWith(item.href), + })), + }, + ]; +} diff --git a/apps/web/src/components/auth-entrypoint.tsx b/apps/web/src/components/auth-entrypoint.tsx index 51c1a21a..a31c12fd 100644 --- a/apps/web/src/components/auth-entrypoint.tsx +++ b/apps/web/src/components/auth-entrypoint.tsx @@ -4,17 +4,12 @@ import type { BetterAuthProviderMetadata, BetterAuthSocialProvider, } from "@hack/auth-contract"; +import type { ReactNode } from "react"; import { useEffect, useMemo, useState } from "react"; +import { GitHubIcon } from "@/components/github-icon"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { CardDescription, CardFooter, CardTitle } from "@/components/ui/card"; import { cn } from "@/lib/utils"; import { @@ -25,8 +20,6 @@ import { type AuthEntrypointProps = { readonly mode: "sign-in" | "account"; - /** When `mode` is `sign-in`, chooses marketing copy (OAuth creates new users on first GitHub login). */ - readonly variant?: "sign-in" | "sign-up"; readonly providers: readonly BetterAuthSocialProvider[]; readonly appBaseUrl: string; readonly authBrokerBaseUrl: string; @@ -67,12 +60,70 @@ type SocialStartPayload = { readonly message?: string; }; +function renderGithubAuthBlock(input: { + readonly actionState: ActionState; + readonly githubOnlyGate: { + readonly body: string; + readonly title: string; + } | null; + readonly githubProvider: BetterAuthSocialProvider | undefined; + readonly onGithubClick: () => void; + readonly providerGate: { + readonly body: string; + readonly title: string; + } | null; +}): ReactNode { + if (input.providerGate) { + return ( +
+

+ {input.providerGate.title} +

+

+ {input.providerGate.body} +

+
+ ); + } + if (input.githubOnlyGate) { + return ( +
+

+ {input.githubOnlyGate.title} +

+

+ {input.githubOnlyGate.body} +

+
+ ); + } + if (!input.githubProvider) { + return null; + } + const githubLoading = + input.actionState.kind === "loading" && + input.actionState.providerId === "github"; + return ( + + ); +} + export function AuthEntrypoint({ mode, - variant = "sign-in", providers, appBaseUrl, - authBrokerBaseUrl, + authBrokerBaseUrl: _authBrokerBaseUrl, trustedOrigins, betterAuthSource, betterAuthEnabled, @@ -227,28 +278,14 @@ export function AuthEntrypoint({ const hasFlowContext = Boolean(flowId && deviceCode); const summary = resolveSummary({ mode, - variant: mode === "sign-in" ? variant : "sign-in", hasFlowContext, - authBrokerBaseUrl, }); const flowStatus = resolveFlowStatus({ flowState, + hasFlowContext, normalizedRedirect, }); const signInHref = buildAuthPageHref({ - page: "sign-in", - flowId, - deviceCode, - redirect: normalizedRedirect, - }); - const signUpHref = buildAuthPageHref({ - page: "sign-up", - flowId, - deviceCode, - redirect: normalizedRedirect, - }); - const accountHref = buildAuthPageHref({ - page: "account", flowId, deviceCode, redirect: normalizedRedirect, @@ -300,22 +337,32 @@ export function AuthEntrypoint({ betterAuthEnabled, providerCount: resolvedProviders.length, }); + const githubProvider = resolvedProviders.find( + (provider) => provider.id === "github" + ); + const githubOnlyGate = + !providerGate && resolvedProviders.length > 0 && !githubProvider + ? { + title: "GitHub sign-in only", + body: "This app uses GitHub OAuth. Enable GitHub in the broker’s social providers list.", + } + : null; + const hideGithubAuthBlock = + mode === "account" && + (flowState.kind === "ready" || flowState.kind === "claimed"); return (
- - -

- Hack -

+
+
{summary.title} {summary.body} - - +
+
{hasFlowContext ? (

@@ -344,48 +391,15 @@ export function AuthEntrypoint({

) : null} - {providerGate ? ( -
-

- {providerGate.title} -

-

- {providerGate.body} -

-
- ) : ( -
-

- {mode === "account" - ? "Continue with a provider" - : "Continue with GitHub"} -

-
- {resolvedProviders.map((provider) => { - const loading = - actionState.kind === "loading" && - actionState.providerId === provider.id; - let buttonLabel = `Continue with ${provider.label}`; - if (loading) { - buttonLabel = `Opening ${provider.label}…`; - } else if (variant === "sign-up") { - buttonLabel = `Sign up with ${provider.label}`; - } - return ( - - ); - })} -
-
- )} + {hideGithubAuthBlock + ? null + : renderGithubAuthBlock({ + actionState, + githubOnlyGate, + githubProvider, + onGithubClick: () => void handleProviderClick("github"), + providerGate, + })}

- - -

+ {mode === "account" ? ( + + - - + {normalizedRedirect && + !shouldAutoNavigateToReturnUrl({ + value: normalizedRedirect, + }) ? ( + + ) : null} + + + ) : null} +
); } @@ -481,41 +478,29 @@ function authPanelClassName( function resolveSummary(input: { readonly mode: "sign-in" | "account"; - readonly variant: "sign-in" | "sign-up"; readonly hasFlowContext: boolean; - readonly authBrokerBaseUrl: string; }): { readonly title: string; readonly body: string } { if (input.mode === "account") { return { title: "Finish your Hack browser handoff", body: input.hasFlowContext ? "Hack will poll the broker-backed session flow here while the browser completes sign-in." - : `Use this route to resume broker-backed auth handoff or continue account management via ${input.authBrokerBaseUrl}.`, - }; - } - if (input.variant === "sign-up") { - return { - title: "Create your Hack account", - body: input.hasFlowContext - ? "This sign-up is linked to a Hack client flow. Continue with GitHub to finish provisioning." - : "Use GitHub to create your account. If you already use Hack, sign in instead.", + : "Use this route to complete browser sign-in and manage your Hack session.", }; } return { - title: "Sign in to Hack", + title: "", body: input.hasFlowContext - ? "This sign-in request came from Hack. Continue with GitHub to finish the CLI and browser handoff." - : "Sign in with GitHub. Sessions are issued by the Hack auth broker.", + ? "This browser tab is linked to a Hack client flow. Authorize GitHub to finish the handoff." + : "", }; } function buildAuthPageHref(input: { - readonly page: "sign-in" | "sign-up" | "account"; readonly flowId?: string; readonly deviceCode?: string; readonly redirect: string | null; }): string { - const path = input.page === "account" ? "/auth/account" : "/auth"; const searchParams = new URLSearchParams(); if (input.flowId) { searchParams.set("flowId", input.flowId); @@ -526,11 +511,8 @@ function buildAuthPageHref(input: { if (input.redirect) { searchParams.set("redirect", input.redirect); } - if (input.page === "sign-up") { - searchParams.set("variant", "sign-up"); - } const query = searchParams.toString(); - return query.length > 0 ? `${path}?${query}` : path; + return query.length > 0 ? `/auth?${query}` : "/auth"; } function buildFlowStatusUrl(input: { @@ -579,6 +561,7 @@ function createFlowErrorState(input: { function resolveFlowStatus(input: { readonly flowState: FlowState; + readonly hasFlowContext: boolean; readonly normalizedRedirect: string | null; }): { readonly title: string; @@ -597,17 +580,28 @@ function resolveFlowStatus(input: { }; } if (input.flowState.kind === "ready" || input.flowState.kind === "claimed") { + const title = + input.hasFlowContext || input.normalizedRedirect + ? "Browser handoff confirmed" + : "Signed in to Hack"; + let body = + "Your browser session is active. You can close this tab or start another sign-in."; + if (input.normalizedRedirect) { + body = + "The broker established the session. Return to Hack when you are ready."; + } else if (input.hasFlowContext) { + body = + "The broker established the session. You can close this tab when you are done."; + } return { - title: "Browser handoff confirmed", - body: input.normalizedRedirect - ? "The broker established the session. Return to Hack when you are ready." - : "The broker established the session. You can close this tab when you are done.", + title, + body, tone: "success", statusText: input.normalizedRedirect && shouldAutoNavigateToReturnUrl({ value: input.normalizedRedirect }) ? "Returning to Hack…" - : "Broker-backed session confirmed.", + : title, ...(input.normalizedRedirect ? { href: input.normalizedRedirect, diff --git a/apps/web/src/components/control-plane-shell.tsx b/apps/web/src/components/control-plane-shell.tsx index ad5e8f6c..a34e7ac7 100644 --- a/apps/web/src/components/control-plane-shell.tsx +++ b/apps/web/src/components/control-plane-shell.tsx @@ -132,11 +132,6 @@ export default function ControlPlaneShell({ return (
-