diff --git a/.env.example b/.env.example index 36b03a6..ac7b924 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,27 @@ -# Copy to apps/web/.env.local for Node hosts. Cloudflare uses the D1 binding in wrangler.jsonc. +# Node hosts: copy to apps/web/.env.local. Cloudflare: put secrets in apps/web/.dev.vars locally +# and in `wrangler secret put` for deploys. The database on Cloudflare is the D1 binding in +# wrangler.jsonc. # libsql URL of the SQLite database (Node hosts only). Relative paths resolve from apps/web. DATABASE_URL=file:.data/gitwork.sqlite + +# Better Auth. Both are required in production. Generate the secret with: openssl rand -base64 32 +# The secret also encrypts stored GitHub tokens: rotating it makes every linked GitHub account +# need to be linked again. +BETTER_AUTH_SECRET= +# The public URL of this instance. Email links and the same-origin check use it. +BETTER_AUTH_URL=http://localhost:3000 + +# Node hosts behind a reverse proxy: the header that carries the real client IP (e.g. x-real-ip). +# Set it only if the proxy always overwrites that header. Unset = no per-IP limits; per-email +# limits still apply. Cloudflare Workers use cf-connecting-ip automatically. +TRUSTED_IP_HEADER= + +# Email for sign-in codes: console (dev only) | smtp (Node) | cloudflare (Workers) +EMAIL_TRANSPORT=console +EMAIL_FROM=Gitwork +SMTP_URL= + +# GitHub App OAuth credentials, for linking GitHub accounts. Phase 2's setup wizard replaces these. +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= diff --git a/CLAUDE.md b/CLAUDE.md index 6084838..fe8626c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,15 +36,21 @@ pnpm db:migrate # apply migrations to the local SQLite file ``` apps/web Next.js app, /api/v1 REST API, GitHub webhooks apps/mac Native macOS app (AppKit + TextKit 2) — Phase 6, not created yet -packages/core Permissions, visibility filter, GitHub sync, attribution, markdown round trip -packages/db Drizzle schema + migrations (SQLite dialect), D1 and file drivers +packages/core Pure domain logic: role matrix (permissions.ts); later visibility, attribution, markdown +packages/server DB- and auth-aware code: Better Auth factory, services, /api/v1 router, email +packages/db Drizzle schema + migrations (SQLite dialect), D1 and file drivers, test helpers docs/ INDEX.md → STATE.md → spec.md, decisions/ ``` ## Rules - Every read and write goes through the permission and visibility checks in `packages/core`. Never - return GitHub data to a client without them. + return GitHub data to a client without them. Services load access with + `requireWorkspaceAccess()` and call `requireCapability()`. +- New `/api/v1` routes go in `packages/server/src/api/routes.ts`, with tests in + `packages/server/test/api.test.ts` (success, 401, 403, 400). +- DB tests run on both drivers: `describe.each(await allTestDrivers())` from `@gitwork/db/testing`. +- Pages read the request (`getSession()`/`connection()`) before any config, or `next build` fails. - Webhook handlers verify the signature and are idempotent on the delivery ID. - The markdown editors must leave untouched lines byte-identical. The round-trip fixture suite guards this; never weaken it. diff --git a/apps/web/cloudflare-env.d.ts b/apps/web/cloudflare-env.d.ts index 15de8c9..5db0e43 100644 --- a/apps/web/cloudflare-env.d.ts +++ b/apps/web/cloudflare-env.d.ts @@ -1,9 +1,12 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-interface=CloudflareEnv cloudflare-env.d.ts` (hash: fe8c79edc0ca2e1a55b87aa44ba71f28) +// Generated by Wrangler by running `wrangler types --env-interface=CloudflareEnv cloudflare-env.d.ts` (hash: 1cd12bfb9b3a0c857bfacfca3906b39f) // Runtime types generated with workerd@1.20261012.1 2026-10-01 global_fetch_strictly_public,nodejs_compat interface __BaseEnv_CloudflareEnv { DB: D1Database; + EMAIL: SendEmail; ASSETS: Fetcher; + EMAIL_TRANSPORT: "cloudflare"; + EMAIL_FROM: "Gitwork "; WORKER_SELF_REFERENCE: Service; } declare namespace Cloudflare { @@ -13,6 +16,12 @@ declare namespace Cloudflare { interface Env extends __BaseEnv_CloudflareEnv {} } interface CloudflareEnv extends __BaseEnv_CloudflareEnv {} +type StringifyValues> = { + [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; +}; +declare namespace NodeJS { + interface ProcessEnv extends StringifyValues> {} +} declare module "*.txt" { const value: string; export default value; diff --git a/apps/web/e2e/identity.spec.ts b/apps/web/e2e/identity.spec.ts new file mode 100644 index 0000000..c5b64de --- /dev/null +++ b/apps/web/e2e/identity.spec.ts @@ -0,0 +1,70 @@ +import { type Page, expect, test } from "@playwright/test"; +import { lastEmail, signInCode } from "./outbox"; + +async function signInWithCode(page: Page, email: string) { + await page.getByLabel("Email").fill(email); + await page.getByRole("button", { name: "Email me a sign-in code" }).click(); + await page.getByLabel("Code").fill(await signInCode(email)); + await page.getByRole("button", { name: "Sign in" }).click(); +} + +test("an owner creates a workspace and a client joins it by invite as Read Only", async ({ browser }) => { + // Owner: sign in, create the account and first workspace, invite the client. + const ownerCtx = await browser.newContext(); + const owner = await ownerCtx.newPage(); + await owner.goto("/"); + await signInWithCode(owner, "owner@example.com"); + await owner.getByLabel("Account name").fill("Acme Agency"); + await owner.getByLabel("First workspace").fill("Client Website"); + await owner.getByRole("button", { name: "Create" }).click(); + await expect(owner.getByRole("heading", { name: "Client Website" })).toBeVisible(); + await expect(owner.getByText("Your role: Owner")).toBeVisible(); + + await owner.getByRole("link", { name: "Members" }).click(); + await owner.getByLabel("Email").fill("client@example.com"); + await owner.getByLabel("Role").selectOption("read_only"); + await owner.getByRole("button", { name: "Send invite" }).click(); + await expect(owner.getByTestId("invite-row")).toContainText("client@example.com"); + + // Client: open the invite link, sign in as the invited email, accept. + const [, invitePath] = await lastEmail("client@example.com", /http:\/\/localhost:\d+(\/invite\/[A-Za-z0-9_-]+)/); + const clientCtx = await browser.newContext(); + const client = await clientCtx.newPage(); + await client.goto(invitePath!); + await expect(client.getByText("You are invited to Client Website as Read Only.")).toBeVisible(); + await client.getByRole("link", { name: "Sign in as client@example.com to accept" }).click(); + await expect(client.getByLabel("Email")).toHaveValue("client@example.com"); + await signInWithCode(client, "client@example.com"); + await client.getByRole("button", { name: "Accept invite" }).click(); + await expect(client.getByRole("heading", { name: "Client Website" })).toBeVisible(); + await expect(client.getByText("Your role: Read Only")).toBeVisible(); + + // Read Only sees the members but cannot invite. + await client.getByRole("link", { name: "Members" }).click(); + await expect(client.getByTestId("member-row")).toHaveCount(2); + await expect(client.getByRole("button", { name: "Send invite" })).toHaveCount(0); + + // The owner now sees the client as a member and no pending invite. + await owner.reload(); + await expect(owner.getByTestId("member-row").filter({ hasText: "client@example.com" })).toContainText("Read Only"); + await expect(owner.getByTestId("invite-row")).toHaveCount(0); + + await ownerCtx.close(); + await clientCtx.close(); +}); + +test("the sign-in link signs in only after a click", async ({ page }) => { + await page.goto("/sign-in"); + await page.getByLabel("Email").fill("linker@example.com"); + await page.getByRole("button", { name: "Email me a sign-in code" }).click(); + const [link] = await lastEmail("linker@example.com", /http:\/\/localhost:\d+\/sign-in\/verify#\S+/); + const url = new URL(link!); + + await page.goto(url.pathname + url.hash); + // Loading the page must not sign in (link scanners load pages). + const session = await page.request.get("/api/auth/get-session"); + expect(await session.json()).toBeNull(); + + await page.getByRole("button", { name: "Continue as linker@example.com" }).click(); + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); +}); diff --git a/apps/web/e2e/outbox.ts b/apps/web/e2e/outbox.ts new file mode 100644 index 0000000..55f436a --- /dev/null +++ b/apps/web/e2e/outbox.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import { expect } from "@playwright/test"; +import { E2E_OUTBOX } from "../playwright.config"; + +interface Message { + to: string; + subject: string; + text: string; +} + +/** The newest email to `to` whose text matches `pattern`. Polls: sending is async. */ +export async function lastEmail(to: string, pattern: RegExp): Promise { + let found: RegExpExecArray | null = null; + await expect + .poll(() => { + let lines: string[] = []; + try { + lines = readFileSync(E2E_OUTBOX, "utf8").trim().split("\n"); + } catch { + return false; + } + const messages = lines.filter(Boolean).map((l) => JSON.parse(l) as Message); + const match = messages + .filter((m) => m.to === to) + .map((m) => pattern.exec(m.text)) + .filter((m): m is RegExpExecArray => m !== null) + .at(-1); + found = match ?? null; + return found !== null; + }) + .toBe(true); + return found!; +} + +export const signInCode = async (email: string) => (await lastEmail(email, /sign-in code is (\d{6})/))[1]!; diff --git a/apps/web/e2e/smoke.spec.ts b/apps/web/e2e/smoke.spec.ts index 9f27baa..640a92c 100644 --- a/apps/web/e2e/smoke.spec.ts +++ b/apps/web/e2e/smoke.spec.ts @@ -1,8 +1,9 @@ import { expect, test } from "@playwright/test"; -test("home page shows the product name", async ({ page }) => { +test("home redirects to sign-in when signed out", async ({ page }) => { await page.goto("/"); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Gitwork"); + await expect(page).toHaveURL(/\/sign-in\?next=%2F$/); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Sign in to Gitwork"); }); test("health endpoint reports the SQLite driver", async ({ request }) => { diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 0a3df06..26476a1 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,21 +1,21 @@ import type { NextConfig } from "next"; // Build-time driver split (decision 004). `pnpm cf:build` sets GITWORK_TARGET=cloudflare, so the -// Worker bundle gets only the D1 runtime; every other build gets only the libsql runtime. A runtime -// branch would let the bundler trace the native libsql module into the Worker. -const dbRuntime = +// Worker bundle gets only the D1 runtime; every other build gets only the libsql + nodemailer runtime. A runtime +// branch would let the bundler trace Node-only modules into the Worker. +const runtime = process.env.GITWORK_TARGET === "cloudflare" - ? "./src/server/db/runtime.workerd.ts" - : "./src/server/db/runtime.node.ts"; + ? "./src/server/runtime/runtime.workerd.ts" + : "./src/server/runtime/runtime.node.ts"; const nextConfig: NextConfig = { // cacheComponents stays off: under workerd its setTimeout dependency hangs every request. reactCompiler: true, - transpilePackages: ["@gitwork/core", "@gitwork/db"], - serverExternalPackages: ["@libsql/client", "libsql"], + transpilePackages: ["@gitwork/core", "@gitwork/db", "@gitwork/server"], + serverExternalPackages: ["@libsql/client", "libsql", "nodemailer"], turbopack: { resolveAlias: { - "@gitwork/db-runtime": dbRuntime, + "@gitwork/runtime": runtime, }, rules: { "*.css": { diff --git a/apps/web/package.json b/apps/web/package.json index f3fe430..6c22a22 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,12 +19,15 @@ "dependencies": { "@gitwork/core": "workspace:*", "@gitwork/db": "workspace:*", + "@gitwork/server": "workspace:*", "@opennextjs/cloudflare": "1.20.11", + "better-auth": "1.7.8", "drizzle-orm": "0.45.4", "next": "16.3.8", "react": "19.3.0", "react-dom": "19.3.0", - "server-only": "^0.0.1" + "server-only": "^0.0.1", + "zod": "4.6.7" }, "devDependencies": { "@playwright/test": "1.64.1", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 0322043..1d7d4a3 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -1,20 +1,29 @@ import { defineConfig, devices } from "@playwright/test"; const PORT = 3124; +export const E2E_OUTBOX = ".data/e2e-outbox.jsonl"; -// E2E against the Node runtime. scripts/smoke.sh covers the Worker runtime. +// E2E against the Node runtime with a fresh database. scripts/smoke.sh covers the Worker runtime. export default defineConfig({ testDir: "./e2e", - fullyParallel: true, + fullyParallel: false, + workers: 1, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, reporter: process.env.CI ? "github" : "list", use: { baseURL: `http://localhost:${PORT}`, trace: "on-first-retry" }, projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], webServer: { - command: `pnpm db:migrate && pnpm build && pnpm start --port ${PORT}`, + command: `rm -f .data/e2e.sqlite* ${E2E_OUTBOX} && pnpm db:migrate && pnpm build && pnpm start --port ${PORT}`, url: `http://localhost:${PORT}/api/v1/health`, timeout: 240_000, reuseExistingServer: false, + env: { + DATABASE_URL: "file:.data/e2e.sqlite", + EMAIL_TRANSPORT: "outbox", + EMAIL_OUTBOX_PATH: E2E_OUTBOX, + BETTER_AUTH_SECRET: "e2e-secret-e2e-secret-e2e-secret-e2e", + BETTER_AUTH_URL: `http://localhost:${PORT}`, + }, }, }); diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..1a009ba --- /dev/null +++ b/apps/web/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,7 @@ +import { getDeps } from "@/server/context"; + +// Better Auth's endpoints: email codes, sessions, GitHub linking. +const handle = (request: Request) => getDeps().auth.handler(request); + +export const GET = handle; +export const POST = handle; diff --git a/apps/web/src/app/api/v1/[...path]/route.ts b/apps/web/src/app/api/v1/[...path]/route.ts new file mode 100644 index 0000000..3843253 --- /dev/null +++ b/apps/web/src/app/api/v1/[...path]/route.ts @@ -0,0 +1,10 @@ +import { handleApiRequest } from "@gitwork/server"; +import { getDeps } from "@/server/context"; + +// Every /api/v1 route lives in @gitwork/server's router (decision 015). +const handle = (request: Request) => handleApiRequest(request, getDeps()); + +export const GET = handle; +export const POST = handle; +export const PATCH = handle; +export const DELETE = handle; diff --git a/apps/web/src/app/api/v1/health/route.ts b/apps/web/src/app/api/v1/health/route.ts index 6110941..eab3271 100644 --- a/apps/web/src/app/api/v1/health/route.ts +++ b/apps/web/src/app/api/v1/health/route.ts @@ -1,7 +1,7 @@ import { APP_NAME } from "@gitwork/core"; import { instanceSettings } from "@gitwork/db"; import { count } from "drizzle-orm"; -import { getDatabase } from "@/server/db"; +import { getDatabase } from "@/server/runtime"; /** Liveness plus a database round trip. Reports which driver served the request. */ export async function GET() { diff --git a/apps/web/src/app/invite/[token]/accept-button.tsx b/apps/web/src/app/invite/[token]/accept-button.tsx new file mode 100644 index 0000000..e5031dc --- /dev/null +++ b/apps/web/src/app/invite/[token]/accept-button.tsx @@ -0,0 +1,33 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button, ErrorText } from "@/components/ui"; +import { api } from "@/lib/api"; + +export function AcceptButton({ token }: { token: string }) { + const router = useRouter(); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + return ( +
+ + {error} +
+ ); +} diff --git a/apps/web/src/app/invite/[token]/page.tsx b/apps/web/src/app/invite/[token]/page.tsx new file mode 100644 index 0000000..bb2b3bd --- /dev/null +++ b/apps/web/src/app/invite/[token]/page.tsx @@ -0,0 +1,65 @@ +import { APP_NAME } from "@gitwork/core"; +import { getInvitePreview } from "@gitwork/server"; +import Link from "next/link"; +import { connection } from "next/server"; +import { SignOutButton } from "@/components/sign-out-button"; +import { Card, ROLE_LABEL } from "@/components/ui"; +import { getDeps, getSession } from "@/server/context"; +import { messageOf } from "@/server/pages"; +import { AcceptButton } from "./accept-button"; + +const STATUS_TEXT = { + accepted: "This invite was already used.", + revoked: "This invite was cancelled.", + expired: "This invite has expired. Ask for a new one.", +} as const; + +export default async function InvitePage({ params }: PageProps<"/invite/[token]">) { + const { token } = await params; + await connection(); + let invite; + try { + invite = await getInvitePreview(getDeps().database.db, token); + } catch (error) { + return {messageOf(error)}; + } + if (invite.status !== "pending") return {STATUS_TEXT[invite.status]}; + + const session = await getSession(); + const path = `/invite/${token}`; + return ( + +

+ You are invited to {invite.workspaceName} as {ROLE_LABEL[invite.role]}. +

+ {!session ? ( + + Sign in as {invite.email} to accept + + ) : session.user.email !== invite.email ? ( +
+

+ This invite is for {invite.email}, but you are signed in as {session.user.email}. +

+ +
+ ) : ( + + )} +
+ ); +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+ +

Join {APP_NAME}

+
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/onboarding.tsx b/apps/web/src/app/onboarding.tsx new file mode 100644 index 0000000..1c4354c --- /dev/null +++ b/apps/web/src/app/onboarding.tsx @@ -0,0 +1,50 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button, Card, ErrorText, Input, Label } from "@/components/ui"; +import { api } from "@/lib/api"; + +export function Onboarding() { + const router = useRouter(); + const [accountName, setAccountName] = useState(""); + const [workspaceName, setWorkspaceName] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(""); + try { + const { workspaceId } = await api<{ workspaceId: string }>("POST", "/accounts", { accountName, workspaceName }); + router.push(`/w/${workspaceId}`); + router.refresh(); + } catch (err) { + setError((err as Error).message); + setBusy(false); + } + } + + return ( + +

Create your account

+

+ An account holds your workspaces. Each workspace is one project. +

+
+
+ + setAccountName(e.target.value)} /> +
+
+ + setWorkspaceName(e.target.value)} /> +
+ {error} + +
+
+ ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index f6e51de..a798648 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,10 +1,40 @@ -import { APP_NAME } from "@gitwork/core"; +import { listMyWorkspaces } from "@gitwork/server"; +import Link from "next/link"; +import { Header } from "@/components/header"; +import { ROLE_LABEL } from "@/components/ui"; +import { getDeps } from "@/server/context"; +import { requireUser } from "@/server/pages"; +import { Onboarding } from "./onboarding"; + +export default async function Home() { + const user = await requireUser("/"); + const workspaces = await listMyWorkspaces(getDeps().database.db, user.id); -export default function Home() { return ( -
-

{APP_NAME}

-

GitHub is the source of truth.

-
+ <> +
+
+ {workspaces.length === 0 ? ( + + ) : ( + <> +

Workspaces

+
    + {workspaces.map((w) => ( +
  • + + + {w.name} + {w.accountName} + + {ROLE_LABEL[w.role]} + +
  • + ))} +
+ + )} +
+ ); } diff --git a/apps/web/src/app/settings/link-github.tsx b/apps/web/src/app/settings/link-github.tsx new file mode 100644 index 0000000..1e3b38f --- /dev/null +++ b/apps/web/src/app/settings/link-github.tsx @@ -0,0 +1,21 @@ +"use client"; +import { useState } from "react"; +import { Button, ErrorText } from "@/components/ui"; +import { authClient } from "@/lib/auth-client"; + +export function LinkGitHubButton() { + const [error, setError] = useState(""); + return ( +
+ + {error} +
+ ); +} diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx new file mode 100644 index 0000000..19e96a0 --- /dev/null +++ b/apps/web/src/app/settings/page.tsx @@ -0,0 +1,35 @@ +import { hasLinkedGitHub } from "@gitwork/server"; +import { Header } from "@/components/header"; +import { Card } from "@/components/ui"; +import { getConfig, getDeps } from "@/server/context"; +import { requireUser } from "@/server/pages"; +import { LinkGitHubButton } from "./link-github"; + +export default async function SettingsPage() { + const user = await requireUser("/settings"); + const linked = await hasLinkedGitHub(getDeps().database.db, user.id); + const githubConfigured = getConfig().github !== undefined; + + return ( + <> +
+
+

Settings

+ +

GitHub

+

+ Link GitHub to install the app and connect repos and boards. When linked, your edits show under + your own GitHub name. +

+ {linked ? ( +

GitHub account linked.

+ ) : githubConfigured ? ( + + ) : ( +

GitHub linking is not set up on this instance yet.

+ )} +
+
+ + ); +} diff --git a/apps/web/src/app/sign-in/page.tsx b/apps/web/src/app/sign-in/page.tsx new file mode 100644 index 0000000..8e00c02 --- /dev/null +++ b/apps/web/src/app/sign-in/page.tsx @@ -0,0 +1,17 @@ +import { APP_NAME } from "@gitwork/core"; +import { Suspense } from "react"; +import { Card } from "@/components/ui"; +import { SignInForm } from "./sign-in-form"; + +export default function SignInPage() { + return ( +
+ +

Sign in to {APP_NAME}

+ + + +
+
+ ); +} diff --git a/apps/web/src/app/sign-in/sign-in-form.tsx b/apps/web/src/app/sign-in/sign-in-form.tsx new file mode 100644 index 0000000..20b1836 --- /dev/null +++ b/apps/web/src/app/sign-in/sign-in-form.tsx @@ -0,0 +1,81 @@ +"use client"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; +import { Button, ErrorText, Input, Label } from "@/components/ui"; +import { safeNext } from "@/lib/api"; +import { authClient } from "@/lib/auth-client"; + +export function SignInForm() { + const router = useRouter(); + const params = useSearchParams(); + const next = safeNext(params.get("next")); + const [email, setEmail] = useState(params.get("email") ?? ""); + const [code, setCode] = useState(""); + const [step, setStep] = useState<"email" | "code">("email"); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function sendCode(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(""); + const { error } = await authClient.emailOtp.sendVerificationOtp({ email, type: "sign-in" }); + setBusy(false); + if (error) return setError(error.message ?? "Could not send the code."); + setStep("code"); + } + + async function verify(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(""); + const { error } = await authClient.signIn.emailOtp({ email, otp: code.trim() }); + setBusy(false); + if (error) return setError(error.message ?? "That code did not work."); + router.push(next); + router.refresh(); + } + + if (step === "email") { + return ( +
+
+ + setEmail(e.target.value)} /> +
+ {error} + +
+ ); + } + + return ( +
+

+ We sent a 6-digit code to {email}. Enter it below, or open the link in the email. +

+
+ + setCode(e.target.value)} + /> +
+ {error} + + +
+ ); +} diff --git a/apps/web/src/app/sign-in/verify/page.tsx b/apps/web/src/app/sign-in/verify/page.tsx new file mode 100644 index 0000000..1df425b --- /dev/null +++ b/apps/web/src/app/sign-in/verify/page.tsx @@ -0,0 +1,13 @@ +import { Card } from "@/components/ui"; +import { VerifyLink } from "./verify-link"; + +export default function VerifyPage() { + return ( +
+ +

Sign in

+ +
+
+ ); +} diff --git a/apps/web/src/app/sign-in/verify/verify-link.tsx b/apps/web/src/app/sign-in/verify/verify-link.tsx new file mode 100644 index 0000000..db3229d --- /dev/null +++ b/apps/web/src/app/sign-in/verify/verify-link.tsx @@ -0,0 +1,53 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Button, ErrorText } from "@/components/ui"; +import { authClient } from "@/lib/auth-client"; + +/** + * Opened from the sign-in email. The code is in the URL fragment, which never reaches the server. + * Nothing happens until the person clicks: email link scanners load pages but do not click. + */ +export function VerifyLink() { + const router = useRouter(); + const [link, setLink] = useState<{ email: string; code: string } | null>(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + useEffect(() => { + const params = new URLSearchParams(window.location.hash.slice(1)); + const email = params.get("email"); + const code = params.get("code"); + // Reading the fragment is a one-time sync from the browser URL into state. + // eslint-disable-next-line react-hooks/set-state-in-effect + if (email && code) setLink({ email, code }); + else setError("This sign-in link is incomplete. Request a new code."); + }, []); + + async function continueSignIn() { + if (!link) return; + setBusy(true); + const { error } = await authClient.signIn.emailOtp({ email: link.email, otp: link.code }); + setBusy(false); + if (error) return setError("This link has expired or was already used. Request a new code."); + history.replaceState(null, "", window.location.pathname); + router.push("/"); + router.refresh(); + } + + return ( +
+ {link && ( + + )} + {error} + {error && ( + + Back to sign in + + )} +
+ ); +} diff --git a/apps/web/src/app/w/[workspaceId]/members/members-panel.tsx b/apps/web/src/app/w/[workspaceId]/members/members-panel.tsx new file mode 100644 index 0000000..4acd1a1 --- /dev/null +++ b/apps/web/src/app/w/[workspaceId]/members/members-panel.tsx @@ -0,0 +1,154 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button, Card, ErrorText, Input, Label, ROLE_LABEL, Select } from "@/components/ui"; +import { api } from "@/lib/api"; + +interface Member { + userId: string; + name: string; + email: string; + role: string; + viaAccount: boolean; +} +interface Invite { + id: string; + email: string; + role: string; +} + +export function MembersPanel(props: { + workspaceId: string; + currentUserId: string; + members: Member[]; + invites: Invite[]; + /** Roles this user may grant; empty when they cannot manage members. */ + grantable: string[]; +}) { + const router = useRouter(); + const [error, setError] = useState(""); + const [email, setEmail] = useState(""); + const [role, setRole] = useState(props.grantable.includes("read_only") ? "read_only" : (props.grantable[0] ?? "")); + const base = `/workspaces/${props.workspaceId}`; + + async function run(action: () => Promise) { + setError(""); + try { + await action(); + router.refresh(); + } catch (err) { + setError((err as Error).message); + } + } + + const canManage = props.grantable.length > 0; + + return ( +
+ {error} + + {canManage && ( + +

Invite someone

+
{ + e.preventDefault(); + void run(async () => { + await api("POST", `${base}/invites`, { email, role }); + setEmail(""); + }); + }} + > +
+ + setEmail(e.target.value)} /> +
+
+ + +
+ +
+
+ )} + +
+

Members

+
    + {props.members.map((m) => { + const self = m.userId === props.currentUserId; + const editable = !m.viaAccount && !self && props.grantable.includes(m.role); + return ( +
  • + + {m.name || m.email} + {m.email} + + + {editable ? ( + + ) : ( + + {ROLE_LABEL[m.role]} + {m.viaAccount && " (account)"} + + )} + {(editable || (self && !m.viaAccount)) && ( + + )} + +
  • + ); + })} +
+
+ + {canManage && props.invites.length > 0 && ( +
+

Pending invites

+
    + {props.invites.map((i) => ( +
  • + + {i.email} · {ROLE_LABEL[i.role]} + + {props.grantable.includes(i.role) && ( + + )} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/w/[workspaceId]/members/page.tsx b/apps/web/src/app/w/[workspaceId]/members/page.tsx new file mode 100644 index 0000000..576c568 --- /dev/null +++ b/apps/web/src/app/w/[workspaceId]/members/page.tsx @@ -0,0 +1,29 @@ +import { can, invitableRoles } from "@gitwork/core"; +import { listInvites, listMembers } from "@gitwork/server"; +import { Header } from "@/components/header"; +import { requireWorkspacePage } from "@/server/pages"; +import { WorkspaceNav } from "../workspace-nav"; +import { MembersPanel } from "./members-panel"; + +export default async function MembersPage({ params }: PageProps<"/w/[workspaceId]/members">) { + const { workspaceId } = await params; + const { user, access, db } = await requireWorkspacePage(workspaceId, `/w/${workspaceId}/members`); + const canInvite = can(access.actor, "members.invite", access.settings); + const [members, invites] = await Promise.all([listMembers(db, access), canInvite ? listInvites(db, access) : []]); + + return ( + <> +
+
+ + ({ id: i.id, email: i.email, role: i.role }))} + grantable={canInvite ? [...invitableRoles(access.role)] : []} + /> +
+ + ); +} diff --git a/apps/web/src/app/w/[workspaceId]/page.tsx b/apps/web/src/app/w/[workspaceId]/page.tsx new file mode 100644 index 0000000..be53f67 --- /dev/null +++ b/apps/web/src/app/w/[workspaceId]/page.tsx @@ -0,0 +1,22 @@ +import { Header } from "@/components/header"; +import { Card } from "@/components/ui"; +import { requireWorkspacePage } from "@/server/pages"; +import { WorkspaceNav } from "./workspace-nav"; + +export default async function WorkspacePage({ params }: PageProps<"/w/[workspaceId]">) { + const { workspaceId } = await params; + const { user, access } = await requireWorkspacePage(workspaceId, `/w/${workspaceId}`); + return ( + <> +
+
+ + +

+ Tasks and docs arrive once this workspace is linked to a GitHub board and repos. +

+
+
+ + ); +} diff --git a/apps/web/src/app/w/[workspaceId]/workspace-nav.tsx b/apps/web/src/app/w/[workspaceId]/workspace-nav.tsx new file mode 100644 index 0000000..43ac2a2 --- /dev/null +++ b/apps/web/src/app/w/[workspaceId]/workspace-nav.tsx @@ -0,0 +1,21 @@ +import Link from "next/link"; +import { ROLE_LABEL } from "@/components/ui"; + +export function WorkspaceNav({ id, name, role }: { id: string; name: string; role: string }) { + return ( +
+
+

{name}

+

Your role: {ROLE_LABEL[role]}

+
+ +
+ ); +} diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx new file mode 100644 index 0000000..a0e7ad7 --- /dev/null +++ b/apps/web/src/components/header.tsx @@ -0,0 +1,19 @@ +import { APP_NAME } from "@gitwork/core"; +import Link from "next/link"; +import { SignOutButton } from "./sign-out-button"; + +export function Header({ email }: { email: string }) { + return ( +
+ + {APP_NAME} + +
+ + {email} + + +
+
+ ); +} diff --git a/apps/web/src/components/sign-out-button.tsx b/apps/web/src/components/sign-out-button.tsx new file mode 100644 index 0000000..4bcd9db --- /dev/null +++ b/apps/web/src/components/sign-out-button.tsx @@ -0,0 +1,20 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { authClient } from "@/lib/auth-client"; +import { Button } from "./ui"; + +export function SignOutButton() { + const router = useRouter(); + return ( + + ); +} diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx new file mode 100644 index 0000000..334e8fb --- /dev/null +++ b/apps/web/src/components/ui.tsx @@ -0,0 +1,71 @@ +import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react"; + +const cx = (...c: (string | false | undefined)[]) => c.filter(Boolean).join(" "); + +export function Button({ + variant = "primary", + className, + ...props +}: ButtonHTMLAttributes & { variant?: "primary" | "secondary" | "danger" }) { + return ( +