feat: add SSH URL validation, inline radio buttons in repo creation dialog

RepositoryCreateDialog (already unified, used everywhere):
- Add isSshUrl() helper to detect git@ and ssh:// URLs
- Require SSH key selection when URL is SSH; show error otherwise
- Inline existing/new radio buttons with smaller styling (.repo-mode-radios)

Tests:
- Update repositories-settings-tab.test.tsx to select SSH key for owner/repo mode
- Mock listSSHKeys in tests

Quality gates: tsc --noEmit pass, npm run build pass, 80/80 tests pass
This commit is contained in:
Developer
2026-06-09 09:54:24 +00:00
parent 349066bcfa
commit 152f87a254
3 changed files with 55 additions and 7 deletions
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
import * as gitRepositoriesApi from "../../../api/git-repositories";
import * as sshKeysApi from "../../../api/ssh-keys";
const mockRepositories = [
{
@@ -35,6 +36,9 @@ describe("RepositoriesSettingsTab", () => {
it("opens create dialog and clones an existing repository", async () => {
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([
{ id: "key-1", name: "My Key", public_key: "ssh-ed25519 AAA...", created_at: "2024-01-01" },
]);
render(<RepositoriesSettingsTab />);
@@ -52,12 +56,17 @@ describe("RepositoriesSettingsTab", () => {
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
target: { value: "demo" },
});
const sshSelect = screen.getByRole("combobox", { name: /ssh key/i });
await waitFor(() => expect(sshSelect.querySelector('option[value="key-1"]')).toBeTruthy());
(sshSelect as HTMLSelectElement).value = "key-1";
fireEvent.change(sshSelect);
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith("proj-1", {
name: "New Repo",
remote_url: "git@git.commumedia.org:alice/demo.git",
ssh_key_id: "key-1",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
@@ -66,6 +75,7 @@ describe("RepositoriesSettingsTab", () => {
it("uses advanced url fallback when requested", async () => {
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([]);
render(<RepositoriesSettingsTab />);
@@ -124,6 +124,11 @@ export const RepositoryCreateDialog = ({
onClose();
};
const isSshUrl = (url: string): boolean => {
const u = url.trim().toLowerCase();
return u.startsWith("git@") || u.startsWith("ssh://");
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setFormError(null);
@@ -140,19 +145,27 @@ export const RepositoryCreateDialog = ({
};
if (createMode === "clone") {
let remoteUrl: string;
if (useAdvancedUrl) {
if (!advancedUrl.trim()) {
setFormError("Remote URL is required for advanced cloning");
return;
}
input.remote_url = advancedUrl.trim();
remoteUrl = advancedUrl.trim();
} else {
if (!owner.trim() || !repoName.trim()) {
setFormError("Owner and repository name are required");
return;
}
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
remoteUrl = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
}
if (isSshUrl(remoteUrl) && !selectedSshKey) {
setFormError("An SSH key is required for SSH URLs");
return;
}
input.remote_url = remoteUrl;
if (selectedSshKey) {
input.ssh_key_id = selectedSshKey;
}
@@ -202,24 +215,24 @@ export const RepositoryCreateDialog = ({
blank bare repo here.
</p>
<form onSubmit={handleSubmit} className="stack">
<div className="form-field">
<label>
<div className="form-field repo-mode-radios">
<label className="repo-mode-label">
<input
type="radio"
name="repository-mode"
checked={createMode === "clone"}
onChange={() => setCreateMode("clone")}
/>
Clone existing repository
<span>Clone existing</span>
</label>
<label>
<label className="repo-mode-label">
<input
type="radio"
name="repository-mode"
checked={createMode === "blank"}
onChange={() => setCreateMode("blank")}
/>
Create blank repository
<span>Create blank</span>
</label>
</div>
<label className="form-field">