Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ bun run test # vitest run
bun run build # tsc -b && vite build
```

By default the dev server binds to localhost and rejects Host headers it
doesn't recognize. Set `VITE_EXPOSE=true` to bind to all interfaces and accept
any Host — useful when reaching the dev server from another device on your
tailnet:

```sh
VITE_EXPOSE=true bun run dev
```

## License

AGPL-3.0 — same as Parachute Vault.
65 changes: 65 additions & 0 deletions src/components/Header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Header } from "@/components/Header";
import { useVaultStore } from "@/lib/vault/store";
import type { VaultRecord } from "@/lib/vault/types";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

function makeVault(partial: Partial<VaultRecord> & Pick<VaultRecord, "id" | "url">): VaultRecord {
return {
name: "",
issuer: partial.url,
clientId: "client-test",
scope: "full",
addedAt: "2026-04-18T00:00:00.000Z",
lastUsedAt: "2026-04-18T00:00:00.000Z",
...partial,
};
}

function renderHeader() {
return render(
<MemoryRouter>
<Header />
</MemoryRouter>,
);
}

describe("Header vault label fallback", () => {
beforeEach(() => {
useVaultStore.setState({ vaults: {}, activeVaultId: null });
});

afterEach(() => {
useVaultStore.setState({ vaults: {}, activeVaultId: null });
});

it("renders the vault name when present", () => {
useVaultStore.setState({
vaults: { a: makeVault({ id: "a", url: "http://localhost:1940", name: "default" }) },
activeVaultId: "a",
});
renderHeader();
expect(screen.getByRole("combobox", { name: /active vault/i })).toHaveTextContent("default");
});

it("falls back to the URL host when name is empty", () => {
useVaultStore.setState({
vaults: { a: makeVault({ id: "a", url: "https://vault.example.com:8443/api", name: "" }) },
activeVaultId: "a",
});
renderHeader();
expect(screen.getByRole("combobox", { name: /active vault/i })).toHaveTextContent(
"vault.example.com:8443",
);
});

it("falls back to the raw URL when both name and URL are unparseable", () => {
useVaultStore.setState({
vaults: { a: makeVault({ id: "a", url: "not a url", name: "" }) },
activeVaultId: "a",
});
renderHeader();
expect(screen.getByRole("combobox", { name: /active vault/i })).toHaveTextContent("not a url");
});
});
16 changes: 13 additions & 3 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useVaultStore } from "@/lib/vault";
import { type VaultRecord, useVaultStore } from "@/lib/vault";
import { Link, useNavigate } from "react-router";

export function Header() {
Expand All @@ -7,7 +7,17 @@ export function Header() {
const activeVaultId = useVaultStore((s) => s.activeVaultId);
const setActiveVault = useVaultStore((s) => s.setActiveVault);

const vaultList = Object.values(vaults).sort((a, b) => a.name.localeCompare(b.name));
const vaultLabel = (v: VaultRecord): string => {
if (v.name) return v.name;
try {
return new URL(v.url).host;
} catch {
return v.url;
}
};
const vaultList = Object.values(vaults).sort((a, b) =>
vaultLabel(a).localeCompare(vaultLabel(b)),
);
const hasVaults = vaultList.length > 0;

return (
Expand All @@ -31,7 +41,7 @@ export function Header() {
>
{vaultList.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
{vaultLabel(v)}
</option>
))}
</select>
Expand Down
8 changes: 8 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@ import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

// Set VITE_EXPOSE=true to bind the dev server to all interfaces and accept any
// Host header — useful when reaching the dev server over a tailnet. Off by default.
const devExposure = process.env.VITE_EXPOSE === "true";

export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
host: devExposure ? "0.0.0.0" : undefined,
allowedHosts: devExposure ? true : undefined,
},
});