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:
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
|
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
|
||||||
import * as gitRepositoriesApi from "../../../api/git-repositories";
|
import * as gitRepositoriesApi from "../../../api/git-repositories";
|
||||||
|
import * as sshKeysApi from "../../../api/ssh-keys";
|
||||||
|
|
||||||
const mockRepositories = [
|
const mockRepositories = [
|
||||||
{
|
{
|
||||||
@@ -35,6 +36,9 @@ describe("RepositoriesSettingsTab", () => {
|
|||||||
it("opens create dialog and clones an existing repository", async () => {
|
it("opens create dialog and clones an existing repository", async () => {
|
||||||
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
||||||
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
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 />);
|
render(<RepositoriesSettingsTab />);
|
||||||
|
|
||||||
@@ -52,12 +56,17 @@ describe("RepositoriesSettingsTab", () => {
|
|||||||
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
|
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
|
||||||
target: { value: "demo" },
|
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 }));
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
||||||
name: "New Repo",
|
name: "New Repo",
|
||||||
remote_url: "git@git.commumedia.org:alice/demo.git",
|
remote_url: "git@git.commumedia.org:alice/demo.git",
|
||||||
|
ssh_key_id: "key-1",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(listMock).toHaveBeenCalledTimes(2);
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
@@ -66,6 +75,7 @@ describe("RepositoriesSettingsTab", () => {
|
|||||||
it("uses advanced url fallback when requested", async () => {
|
it("uses advanced url fallback when requested", async () => {
|
||||||
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
||||||
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
||||||
|
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([]);
|
||||||
|
|
||||||
render(<RepositoriesSettingsTab />);
|
render(<RepositoriesSettingsTab />);
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ export const RepositoryCreateDialog = ({
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSshUrl = (url: string): boolean => {
|
||||||
|
const u = url.trim().toLowerCase();
|
||||||
|
return u.startsWith("git@") || u.startsWith("ssh://");
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent) => {
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
@@ -140,19 +145,27 @@ export const RepositoryCreateDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (createMode === "clone") {
|
if (createMode === "clone") {
|
||||||
|
let remoteUrl: string;
|
||||||
if (useAdvancedUrl) {
|
if (useAdvancedUrl) {
|
||||||
if (!advancedUrl.trim()) {
|
if (!advancedUrl.trim()) {
|
||||||
setFormError("Remote URL is required for advanced cloning");
|
setFormError("Remote URL is required for advanced cloning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
input.remote_url = advancedUrl.trim();
|
remoteUrl = advancedUrl.trim();
|
||||||
} else {
|
} else {
|
||||||
if (!owner.trim() || !repoName.trim()) {
|
if (!owner.trim() || !repoName.trim()) {
|
||||||
setFormError("Owner and repository name are required");
|
setFormError("Owner and repository name are required");
|
||||||
return;
|
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) {
|
if (selectedSshKey) {
|
||||||
input.ssh_key_id = selectedSshKey;
|
input.ssh_key_id = selectedSshKey;
|
||||||
}
|
}
|
||||||
@@ -202,24 +215,24 @@ export const RepositoryCreateDialog = ({
|
|||||||
blank bare repo here.
|
blank bare repo here.
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={handleSubmit} className="stack">
|
<form onSubmit={handleSubmit} className="stack">
|
||||||
<div className="form-field">
|
<div className="form-field repo-mode-radios">
|
||||||
<label>
|
<label className="repo-mode-label">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="repository-mode"
|
name="repository-mode"
|
||||||
checked={createMode === "clone"}
|
checked={createMode === "clone"}
|
||||||
onChange={() => setCreateMode("clone")}
|
onChange={() => setCreateMode("clone")}
|
||||||
/>
|
/>
|
||||||
Clone existing repository
|
<span>Clone existing</span>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label className="repo-mode-label">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="repository-mode"
|
name="repository-mode"
|
||||||
checked={createMode === "blank"}
|
checked={createMode === "blank"}
|
||||||
onChange={() => setCreateMode("blank")}
|
onChange={() => setCreateMode("blank")}
|
||||||
/>
|
/>
|
||||||
Create blank repository
|
<span>Create blank</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
|
|||||||
@@ -137,3 +137,28 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline radio buttons for repository creation mode */
|
||||||
|
.repo-mode-radios {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-mode-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-mode-label input[type="radio"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--brand);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user