From 152f87a254f1e25ea765a550ad4b704dc5d443f4 Mon Sep 17 00:00:00 2001
From: Developer
Date: Tue, 9 Jun 2026 09:54:24 +0000
Subject: [PATCH] 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
---
.../repositories-settings-tab.test.tsx | 10 +++++++
.../project/repository-create-dialog.tsx | 27 ++++++++++++++-----
apps/web/src/styles/pages/projects.css | 25 +++++++++++++++++
3 files changed, 55 insertions(+), 7 deletions(-)
diff --git a/apps/web/src/components/features/project/repositories-settings-tab.test.tsx b/apps/web/src/components/features/project/repositories-settings-tab.test.tsx
index 4c7702f..65a74cc 100644
--- a/apps/web/src/components/features/project/repositories-settings-tab.test.tsx
+++ b/apps/web/src/components/features/project/repositories-settings-tab.test.tsx
@@ -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();
@@ -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();
diff --git a/apps/web/src/components/features/project/repository-create-dialog.tsx b/apps/web/src/components/features/project/repository-create-dialog.tsx
index c867075..cb5b80a 100644
--- a/apps/web/src/components/features/project/repository-create-dialog.tsx
+++ b/apps/web/src/components/features/project/repository-create-dialog.tsx
@@ -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.