Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Implement simplified authentication flow
Thomas Scholtes committed 2 years ago
commit 2b03aec64515ae9fa6e7bd4d9f1bfa7131c64e9f
parent 448c76283156f0929aaa52bc54a6a965cdb14f8c
11 files changed +66 -322
modified httpd-client/tests/session.test.ts
@@ -6,7 +6,6 @@ import { HttpdClient } from "@httpd-client";
import { authenticate } from "@httpd-client/tests/support/httpd.js";
import { createPeerManager } from "@tests/support/peerManager.js";
import { gitOptions } from "@tests/support/fixtures.js";
-
import { sessionPayloadSchema } from "@httpd-client/lib/session";
import { tmpDir } from "@tests/support/support.js";

describe("session", async () => {
@@ -29,18 +28,7 @@ describe("session", async () => {
  });

  test("#update(id, {sig, pk})", async () => {
-
    const { stdout } = await peer.rad(["web", "--backend", api.url, "--json"]);
-
    const session = sessionPayloadSchema.safeParse(JSON.parse(stdout));
-

-
    if (!session.success) {
-
      throw new Error("Failed to parse session payload");
-
    }
-

-
    const { sessionId, signature, publicKey } = session.data;
-
    await api.session.update(sessionId, {
-
      sig: signature,
-
      pk: publicKey,
-
    });
+
    await authenticate(api, peer);
  });

  test("#delete(id)", async () => {
modified httpd-client/tests/support/httpd.ts
@@ -1,24 +1,31 @@
import type { HttpdClient } from "@httpd-client";
import type { RadiclePeer } from "@tests/support/peerManager.js";

-
import { sessionPayloadSchema } from "@httpd-client/lib/session.js";
+
import assert from "node:assert";

export async function authenticate(
  api: HttpdClient,
  peer: RadiclePeer,
): Promise<string> {
-
  const { stdout } = await peer.rad(["web", "--backend", api.url, "--json"]);
-
  const session = sessionPayloadSchema.safeParse(JSON.parse(stdout));
+
  const { stdout } = await peer.spawn("rad-web", [
+
    "http://localhost:3001",
+
    "--no-open",
+
    "--connect",
+
    "--listen",
+
    `${peer.httpdBaseUrl.hostname}:${peer.httpdBaseUrl.port}`,
+
  ]);
+
  const match = stdout.match(/Visit (http:\/\/\S+) to connect/);
+
  assert(
+
    match !== null && match[1],
+
    `Failed to get authentication URL from: ${stdout}`,
+
  );

-
  if (!session.success) {
-
    throw new Error("Failed to parse session payload");
-
  }
+
  const authUrl = new URL(match[1]);
+
  const sessionId = authUrl.pathname.split("/")[2];

-
  const { sessionId, signature, publicKey } = session.data;
  await api.session.update(sessionId, {
-
    sig: signature,
-
    pk: publicKey,
+
    sig: authUrl.searchParams.get("sig")!,
+
    pk: authUrl.searchParams.get("pk")!,
  });
-

  return sessionId;
}
modified src/App/Header/Authenticate.svelte
@@ -1,12 +1,11 @@
<script lang="ts">
  import * as httpd from "@app/lib/httpd";
-
  import * as modal from "@app/lib/modal";
  import { closeFocused } from "@app/components/Popover.svelte";
  import { httpdStore } from "@app/lib/httpd";

  import Avatar from "@app/components/Avatar.svelte";
  import Button from "@app/components/Button.svelte";
-
  import ConnectModal from "@app/modals/ConnectModal.svelte";
+
  import Command from "@app/components/Command.svelte";
  import IconButton from "@app/components/IconButton.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
  import NodeId from "@app/components/NodeId.svelte";
@@ -78,31 +77,23 @@
      </div>
    </div>
  </Popover>
-
{:else if $httpdStore.state === "running"}
-
  <Button
-
    on:click={() => {
-
      modal.show({
-
        component: ConnectModal,
-
        props: {},
-
      });
-
    }}
-
    size="large"
-
    variant="secondary-toggle-off">
-
    <IconSmall name="key" />
-
    Authenticate
-
    <div class="indicator" />
-
  </Button>
{:else}
-
  <Button
-
    on:click={() => {
-
      modal.show({
-
        component: ConnectModal,
-
        props: {},
-
      });
-
    }}
-
    size="large"
-
    variant="secondary-toggle-off">
-
    <IconSmall name="chat" />
-
    Authenticate
-
  </Button>
+
  <Popover popoverPositionTop="3rem" popoverPositionRight="0">
+
    <Button
+
      slot="toggle"
+
      let:toggle
+
      on:click={toggle}
+
      size="large"
+
      variant="secondary-toggle-off">
+
      <IconSmall name="key" />
+
      Authenticate
+
      <div class="indicator" />
+
    </Button>
+
    <div slot="popover" class="connect-popover">
+
      <div style:margin-bottom="1em">
+
        Authenticate with your local backend to make changes
+
      </div>
+
      <Command fullWidth command={`rad-web ${window.origin} --connect`} />
+
    </div>
+
  </Popover>
{/if}
modified src/App/Header/Connect.svelte
@@ -7,7 +7,6 @@

  import Button from "@app/components/Button.svelte";
  import Command from "@app/components/Command.svelte";
-
  import ExternalLink from "@app/components/ExternalLink.svelte";
  import IconButton from "@app/components/IconButton.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
  import Link from "@app/components/Link.svelte";
@@ -33,17 +32,14 @@
    align-items: center;
    font-size: var(--font-size-small);
  }
-
  .label {
-
    display: block;
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-regular);
-
    margin-bottom: 0.75rem;
-
  }
  .status {
    font-size: var(--font-size-tiny);
    color: var(--color-fill-gray);
    text-align: left;
  }
+
  .connect-popover {
+
    font-size: var(--font-size-small);
+
  }
</style>

{#if $httpdStore.state === "stopped"}
@@ -58,17 +54,12 @@
      <IconSmall name="device" />
      Connect
    </Button>
-

-
    <div slot="popover" style:width="23rem">
-
      <div class="label">
-
        Use the
-
        <ExternalLink href="https://radicle.xyz/#try">Radicle CLI</ExternalLink>
-
        to connect your device.
-
      </div>
-
      <div class="label">
-
        Run the following command to start the httpd daemon.
+
    <div slot="popover" class="connect-popover">
+
      <div style:margin-bottom="1em">
+
        Start the backend to browse projecs on your local machine, create
+
        issues, and participate in discussions.
      </div>
-
      <Command command="radicle-httpd" fullWidth />
+
      <Command fullWidth command={`rad-web ${window.origin}`} />
    </div>
  </Popover>
{:else}
modified src/lib/router.ts
@@ -220,6 +220,7 @@ function urlToRoute(url: URL): Route | null {
            id,
            signature: url.searchParams.get("sig") ?? "",
            publicKey: url.searchParams.get("pk") ?? "",
+
            apiAddr: url.searchParams.get("addr") ?? "127.0.0.1:8080",
          },
        };
      }
modified src/lib/router/definitions.ts
@@ -20,7 +20,7 @@ export interface NotFoundRoute {

interface SessionRoute {
  resource: "session";
-
  params: { id: string; signature: string; publicKey: string };
+
  params: { id: string; signature: string; publicKey: string; apiAddr: string };
}

export interface LoadErrorRoute {
deleted src/modals/ConnectModal.svelte
@@ -1,193 +0,0 @@
-
<script lang="ts">
-
  import * as httpd from "@app/lib/httpd";
-
  import * as modal from "@app/lib/modal";
-
  import { httpdStore } from "@app/lib/httpd";
-

-
  import Command from "@app/components/Command.svelte";
-
  import Modal from "@app/components/Modal.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
-

-
  $: customUrl = `${httpd.api.baseUrl.scheme}://${httpd.api.baseUrl.hostname}:${customPort}`;
-
  $: command = `rad web --frontend ${
-
    new URL(import.meta.url).origin
-
  } --backend ${customUrl}`;
-
  let customPort = httpd.api.port;
-
  $: validPortNumber = Number(customPort) > 0 && Number(customPort) <= 65535;
-

-
  $: if ($httpdStore.state === "authenticated") {
-
    modal.hide();
-
  }
-
</script>
-

-
<style>
-
  .container {
-
    display: flex;
-
    flex-direction: column;
-
    width: 33rem;
-
    margin-top: 1.5rem;
-
    gap: 1.5rem;
-
  }
-
  .progress {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 0.5rem;
-
  }
-
  .progress-bar {
-
    height: 6px;
-
    border-radius: var(--border-radius-round);
-
    background-color: var(--color-background-dip);
-
  }
-
  .bar {
-
    display: flex;
-
    background-color: var(--color-fill-secondary);
-
    height: 100%;
-
    border-radius: var(--border-radius-round);
-
  }
-
  .captions {
-
    display: grid;
-
    grid-template-columns: 1fr 1fr 1fr;
-
    color: var(--color-foreground-dim);
-
    font-size: var(--font-size-tiny);
-
    text-align: center;
-
  }
-

-
  .input {
-
    display: flex;
-
    flex-direction: column;
-
    align-items: flex-start;
-
    gap: 0.75rem;
-
  }
-

-
  .status {
-
    font-size: var(--font-size-tiny);
-
    color: var(--color-fill-gray);
-
  }
-
  .separator {
-
    height: 1px;
-
    background-color: var(--color-border-hint);
-
  }
-

-
  .host {
-
    display: flex;
-
    justify-content: space-between;
-
    align-items: center;
-
    font-size: var(--font-size-small);
-
  }
-

-
  .label {
-
    font-size: var(--font-size-small);
-
  }
-
</style>
-

-
<Modal title="Connect and authenticate">
-
  <Icon name="key" size="48" slot="icon" />
-

-
  <svelte:fragment slot="subtitle">
-
    Complete the steps below to browse projects on your local machine,
-
    <br />
-
    create issues, and participate in discussions.
-
  </svelte:fragment>
-

-
  <div class="container" slot="body">
-
    {#if $httpdStore.state === "stopped"}
-
      <div class="progress">
-
        <div class="progress-bar">
-
          <div class="bar" style:width="1%" />
-
        </div>
-
        <div class="captions">
-
          <div
-
            style:text-align="left"
-
            style:color="var(--color-fill-secondary)">
-
            Start httpd server
-
          </div>
-
          <div>Authenticate</div>
-
          <div style:text-align="right">Done</div>
-
        </div>
-
      </div>
-

-
      <div class="input">
-
        <div class="label">
-
          Run this command in your terminal to connect to your local node:
-
        </div>
-
        <Command fullWidth command="radicle-httpd" />
-
      </div>
-

-
      <div class="input">
-
        <div class="label">Port:</div>
-
        <div style="width: 100%;">
-
          <TextInput
-
            name="port"
-
            size="small"
-
            bind:value={customPort}
-
            valid={validPortNumber}
-
            validationMessage="Invalid port"
-
            on:submit={() => httpd.changeHttpdPort(Number(customPort))}>
-
            <div
-
              slot="right"
-
              style="height: 100%; display: flex; align-items: center; padding: 0 0.5rem 0 0.25rem;">
-
              <IconSmall name="edit" />
-
            </div>
-
          </TextInput>
-
        </div>
-
      </div>
-
    {:else if $httpdStore.state === "running"}
-
      <div class="progress">
-
        <div class="progress-bar">
-
          <div class="bar" style:width="50%" />
-
        </div>
-
        <div class="captions">
-
          <div style:text-align="left">Start httpd server</div>
-
          <div style:color="var(--color-fill-secondary)">Authenticate</div>
-
          <div style:text-align="right">Done</div>
-
        </div>
-
      </div>
-

-
      <div style="display: flex; flex-direction: column; gap: 0.5rem;">
-
        <div class="status">Httpd server running</div>
-
        <div class="host">
-
          radicle.local
-
          <Link
-
            on:afterNavigate={modal.hide}
-
            route={{
-
              resource: "nodes",
-
              params: {
-
                baseUrl: httpd.api.baseUrl,
-
                projectPageIndex: 0,
-
              },
-
            }}>
-
            <IconButton>Browse</IconButton>
-
          </Link>
-
        </div>
-
      </div>
-
      <div class="separator" />
-
      <div class="input">
-
        <div class="label">
-
          Run this command in your terminal to authenticate yourself:
-
        </div>
-
        <Command fullWidth {command} />
-
      </div>
-
      <div class="input">
-
        <div class="label">Port:</div>
-
        <div style="width: 100%;">
-
          <TextInput
-
            name="port"
-
            size="small"
-
            bind:value={customPort}
-
            valid={validPortNumber}
-
            validationMessage="Invalid port"
-
            on:submit={() => httpd.changeHttpdPort(Number(customPort))}>
-
            <div
-
              slot="right"
-
              style="height: 100%; display: flex; align-items: center; padding: 0 0.5rem 0 0.25rem;">
-
              <IconSmall name="edit" />
-
            </div>
-
          </TextInput>
-
        </div>
-
      </div>
-
    {/if}
-
  </div>
-
</Modal>
modified src/views/session/Index.svelte
@@ -14,6 +14,10 @@
  export let activeRoute: Extract<Route, { resource: "session" }>;

  onMount(async () => {
+
    const port = Number.parseInt(activeRoute.params.apiAddr.split(":")[1]);
+
    if (port > 0 && port < 2 ** 16) {
+
      httpd.changeHttpdPort(port);
+
    }
    const isAuthenticated = await httpd.authenticate(activeRoute.params);

    if (isAuthenticated) {
deleted tests/e2e/httpd.spec.ts
@@ -1,27 +0,0 @@
-
import { expect, test } from "@tests/support/fixtures.js";
-

-
test("rad web command reacts to port change", async ({ page, peerManager }) => {
-
  const peer = await peerManager.createPeer({
-
    name: "port-test",
-
  });
-
  await peer.startHttpd(8090);
-

-
  await page.goto("/");
-
  await page.getByRole("button", { name: "Authenticate" }).click();
-

-
  await expect(
-
    page.getByText(
-
      "rad web --frontend http://localhost:3001 --backend http://127.0.0.1:8081",
-
    ),
-
  ).toBeVisible();
-
  await page.locator('input[name="port"]').fill("8090");
-
  await page.locator('input[name="port"]').press("Enter");
-

-
  await expect(
-
    page.getByText(
-
      "rad web --frontend http://localhost:3001 --backend http://127.0.0.1:8090",
-
    ),
-
  ).toBeVisible();
-

-
  await peer.stopHttpd();
-
});
modified tests/support/fixtures.ts
@@ -2,8 +2,8 @@
import type * as Stream from "node:stream";
import * as Fs from "node:fs/promises";
import * as Path from "node:path";
+
import assert from "node:assert";
import { test as base, expect, type ViewportSize } from "@playwright/test";
-
import { object, string, ZodSchema } from "zod";

import * as Process from "./process.js";
import * as issue from "@tests/support/cobs/issue.js";
@@ -20,18 +20,6 @@ const fixturesDir = Path.resolve(supportDir, "..", "./fixtures");

type ViewportTypes = "iPhoneXR" | "Desktop";

-
interface Auth {
-
  sessionId: string;
-
  publicKey: string;
-
  signature: string;
-
}
-

-
const authSchema = object({
-
  sessionId: string(),
-
  publicKey: string(),
-
  signature: string(),
-
}) satisfies ZodSchema<Auth>;
-

export const viewportSizes: Record<ViewportTypes, ViewportSize> = {
  iPhoneXR: { width: 414, height: 896 },
  Desktop: { width: 1280, height: 720 },
@@ -167,28 +155,22 @@ export const test = base.extend<{

    await peer.startHttpd();
    await peer.startNode();
-
    await page.goto("/");
-
    await page.getByRole("button", { name: "Authenticate" }).click();
-
    await page
-
      .locator('input[name="port"]')
-
      .fill(peer.httpdBaseUrl.port.toString());
-
    await page.locator('input[name="port"]').press("Enter");
    const { stdout } = await peer.spawn("rad-web", [
-
      "--frontend",
      "http://localhost:3001",
-
      "--backend",
-
      `${peer.httpdBaseUrl.scheme}://${peer.httpdBaseUrl.hostname}:${peer.httpdBaseUrl.port}`,
-
      "--json",
+
      "--no-open",
+
      "--connect",
+
      "--listen",
+
      `${peer.httpdBaseUrl.hostname}:${peer.httpdBaseUrl.port}`,
    ]);
-
    const result = authSchema.safeParse(JSON.parse(stdout));
-
    if (result.success) {
-
      const { sessionId, publicKey, signature } = result.data;
-
      await page.goto(`/session/${sessionId}?pk=${publicKey}&sig=${signature}`);
-
      await expect(page.getByText("Authenticated")).toBeVisible();
-
      await page.getByRole("button", { name: "Close" }).click();
-
    } else {
-
      throw new Error("Not able to parse rad web output");
-
    }
+
    const match = stdout.match(
+
      /Open the following URL to connect: (http:\/\/\S+)/,
+
    );
+
    assert(
+
      match !== null && match[1],
+
      `Failed to get authentication URL from: ${stdout}`,
+
    );
+
    await page.goto(match[1]);
+
    await page.getByText("Successfully authenticated").waitFor();

    await use(peer);
  },
modified tests/support/heartwood-version
@@ -1 +1 @@
-
6ca7da276839e5217b340dbfff8cd9095d8464c5
+
59f506dbb5591d3fe68e638038495730c455d72a