mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-30 22:21:58 +01:00
feat(ui): organizations page follows the configured source (#403)
The organizations page and the add dialog now follow the configured source: provider label, instance URL, icon and the organization noun (group on GitLab). The billing manager role only appears for GitHub. Adding an organization whose name contains a slash returns 400 with a hint to use the top level group. New tests cover getOrganization and listOrganizationRepositories on the GitLab and Gitea adapters.
This commit is contained in:
1 parent
34babd4d8c
commit
4478c32db1
10 files changed
+174
-26
No files matched your search
@@ -36,6 +36,7 @@ The mirror itself is done by Gitea's pull mirror, which treats every source as a
|
||||
| Cleanup of repositories deleted upstream | yes | yes | yes |
|
||||
| Starred repositories | yes | yes | yes |
|
||||
| Private repositories | yes | yes | yes |
|
||||
| Entire organizations (GitLab: top level groups) | yes | yes | yes |
|
||||
| Add a single repository by URL | yes | yes | yes |
|
||||
| Issues, pull requests, labels, milestones | yes | no | no |
|
||||
| Releases with assets | yes | no | no |
|
||||
|
||||
@@ -14,6 +14,12 @@ import type { MembershipRole } from "@/types/organizations";
|
||||
import { RadioGroup, RadioGroupItem } from "../ui/radio";
|
||||
import { Label } from "../ui/label";
|
||||
import { parseGitHubOwnerReference } from "@/lib/utils/github-url";
|
||||
import {
|
||||
SOURCE_PROVIDER_DEFAULT_URLS,
|
||||
SOURCE_PROVIDER_LABELS,
|
||||
SOURCE_PROVIDER_ORG_NOUNS,
|
||||
type SourceProviderKind,
|
||||
} from "@/lib/source-providers/kinds";
|
||||
|
||||
const inputClassName =
|
||||
"w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
|
||||
@@ -30,12 +36,14 @@ interface AddOrganizationDialogProps {
|
||||
role: MembershipRole;
|
||||
force?: boolean;
|
||||
}) => Promise<void>;
|
||||
sourceProvider?: SourceProviderKind;
|
||||
}
|
||||
|
||||
export default function AddOrganizationDialog({
|
||||
isDialogOpen,
|
||||
setIsDialogOpen,
|
||||
onAddOrganization,
|
||||
sourceProvider = "github",
|
||||
}: AddOrganizationDialogProps) {
|
||||
const [url, setUrl] = useState<string>("");
|
||||
const [org, setOrg] = useState<string>("");
|
||||
@@ -43,6 +51,11 @@ export default function AddOrganizationDialog({
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const providerLabel = SOURCE_PROVIDER_LABELS[sourceProvider];
|
||||
const orgNoun = SOURCE_PROVIDER_ORG_NOUNS[sourceProvider];
|
||||
const orgNounCapitalized = orgNoun.charAt(0).toUpperCase() + orgNoun.slice(1);
|
||||
const urlPlaceholder = `${SOURCE_PROVIDER_DEFAULT_URLS[sourceProvider]}/your-${orgNoun}`;
|
||||
|
||||
const resetForm = () => {
|
||||
setError("");
|
||||
setUrl("");
|
||||
@@ -91,8 +104,8 @@ export default function AddOrganizationDialog({
|
||||
if (!org || org.trim() === "") {
|
||||
setError(
|
||||
urlIsUnparsed
|
||||
? "That does not look like a GitHub organization URL."
|
||||
: "Please enter a valid organization name."
|
||||
? `That does not look like a ${providerLabel} ${orgNoun} URL.`
|
||||
: `Please enter a valid ${orgNoun} name.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -121,9 +134,9 @@ export default function AddOrganizationDialog({
|
||||
|
||||
<DialogContent className="w-[calc(100%-2rem)] sm:max-w-[425px] gap-0 gap-y-6 mx-4 sm:mx-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Organization</DialogTitle>
|
||||
<DialogTitle>Add {orgNounCapitalized}</DialogTitle>
|
||||
<DialogDescription>
|
||||
You can add public organizations
|
||||
{`You can add public ${orgNoun}s`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -134,7 +147,7 @@ export default function AddOrganizationDialog({
|
||||
htmlFor="organizationUrl"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
GitHub URL
|
||||
{providerLabel} URL
|
||||
</label>
|
||||
<input
|
||||
id="organizationUrl"
|
||||
@@ -142,14 +155,14 @@ export default function AddOrganizationDialog({
|
||||
value={url}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
className={inputClassName}
|
||||
placeholder="https://github.com/microsoft"
|
||||
placeholder={urlPlaceholder}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{urlIsUnparsed
|
||||
? "Could not read an organization from that."
|
||||
: "Paste an organization URL and the name below fills in."}
|
||||
: `Paste a ${orgNoun} URL and the name below fills in.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +182,7 @@ export default function AddOrganizationDialog({
|
||||
htmlFor="organizationName"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Organization Name
|
||||
{orgNounCapitalized} Name
|
||||
</label>
|
||||
<input
|
||||
id="organizationName"
|
||||
@@ -178,7 +191,7 @@ export default function AddOrganizationDialog({
|
||||
onChange={(e) => setOrg(e.target.value)}
|
||||
onPaste={handleReferencePaste}
|
||||
className={inputClassName}
|
||||
placeholder="e.g., microsoft"
|
||||
placeholder={`e.g., your-${orgNoun}`}
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
@@ -202,10 +215,12 @@ export default function AddOrganizationDialog({
|
||||
<RadioGroupItem value="admin" id="r2" />
|
||||
<Label htmlFor="r2">Admin</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="billing_manager" id="r3" />
|
||||
<Label htmlFor="r3">Billing Manager</Label>
|
||||
</div>
|
||||
{sourceProvider === "github" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="billing_manager" id="r3" />
|
||||
<Label htmlFor="r3">Billing Manager</Label>
|
||||
</div>
|
||||
)}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
@@ -225,7 +240,7 @@ export default function AddOrganizationDialog({
|
||||
{isLoading ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Add Organization"
|
||||
`Add ${orgNounCapitalized}`
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@ export function Organization() {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
const { user } = useAuth();
|
||||
const { isGitHubConfigured } = useConfigStatus();
|
||||
const { isGitHubConfigured, sourceProvider } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
const { registerRefreshCallback } = useLiveRefresh();
|
||||
const { filter, setFilter } = useFilterParams({
|
||||
@@ -886,12 +886,14 @@ export function Organization() {
|
||||
onRefresh={async () => {
|
||||
await fetchOrganizations(false);
|
||||
}}
|
||||
sourceProvider={sourceProvider}
|
||||
/>
|
||||
|
||||
<AddOrganizationDialog
|
||||
onAddOrganization={handleAddOrganization}
|
||||
isDialogOpen={isDialogOpen}
|
||||
setIsDialogOpen={setIsDialogOpen}
|
||||
sourceProvider={sourceProvider}
|
||||
/>
|
||||
|
||||
<Dialog open={isDuplicateOrgDialogOpen} onOpenChange={(open) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban, SlidersHorizontal, Trash2 } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import { SiGithub, SiGitea, SiGitlab } from "react-icons/si";
|
||||
import type { MirrorOverrides, Organization } from "@/lib/db/schema";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import Fuse from "fuse.js";
|
||||
@@ -16,7 +16,14 @@ import type { OrganizationMoveResult } from "@/lib/destination-transfer";
|
||||
import { MirrorOverridesDialog } from "@/components/config/MirrorOverridesDialog";
|
||||
import { hasMirrorOverrides, mirrorOptionsToFlags } from "@/lib/utils/mirror-overrides";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
import { getCachedConfig } from "@/hooks/useConfigStatus";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import {
|
||||
SOURCE_PROVIDER_LABELS,
|
||||
SOURCE_PROVIDER_ORG_NOUNS,
|
||||
normalizeSourceUrl,
|
||||
type SourceProviderKind,
|
||||
} from "@/lib/source-providers/kinds";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -37,6 +44,7 @@ interface OrganizationListProps {
|
||||
onAddOrganization?: () => void;
|
||||
onRefresh?: () => Promise<void>;
|
||||
onDelete?: (orgId: string) => void;
|
||||
sourceProvider?: SourceProviderKind;
|
||||
}
|
||||
|
||||
// Helper function to get status badge variant and icon
|
||||
@@ -68,10 +76,19 @@ export function OrganizationList({
|
||||
onAddOrganization,
|
||||
onRefresh,
|
||||
onDelete,
|
||||
sourceProvider = "github",
|
||||
}: OrganizationListProps) {
|
||||
const { giteaConfig, mirrorOptions, advancedOptions } = useGiteaConfig();
|
||||
const [overridesTarget, setOverridesTarget] = useState<Organization | null>(null);
|
||||
|
||||
const sourceLabel = SOURCE_PROVIDER_LABELS[sourceProvider];
|
||||
const sourceUrl = normalizeSourceUrl(
|
||||
getCachedConfig()?.githubConfig?.url,
|
||||
sourceProvider
|
||||
);
|
||||
const sourceShortLabel = sourceProvider === "gitea" ? "Gitea" : sourceLabel;
|
||||
const SourceIcon = { github: SiGithub, gitlab: SiGitlab, gitea: SiGitea }[sourceProvider];
|
||||
|
||||
const handleUpdateMirrorOverrides = async (
|
||||
orgId: string,
|
||||
overrides: MirrorOverrides | null
|
||||
@@ -206,7 +223,7 @@ export function OrganizationList({
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
|
||||
{hasAnyFilter
|
||||
? "Try adjusting your search or filter criteria."
|
||||
: "Add GitHub organizations to mirror their repositories."}
|
||||
: `Add ${SOURCE_PROVIDER_LABELS[sourceProvider]} ${SOURCE_PROVIDER_ORG_NOUNS[sourceProvider]}s to mirror their repositories.`}
|
||||
</p>
|
||||
{hasAnyFilter ? (
|
||||
<Button
|
||||
@@ -583,14 +600,14 @@ export function OrganizationList({
|
||||
})()}
|
||||
<Button variant="outline" size="default" asChild className="flex-1 h-10 min-w-0">
|
||||
<a
|
||||
href={`https://github.com/${org.name}`}
|
||||
href={`${sourceUrl}/${org.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
title={`View on ${sourceLabel}`}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<SiGithub className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-xs">GitHub</span>
|
||||
<SourceIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-xs">{sourceShortLabel}</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -759,13 +776,13 @@ export function OrganizationList({
|
||||
className="rounded-none rounded-r-md"
|
||||
>
|
||||
<a
|
||||
href={`https://github.com/${org.name}`}
|
||||
href={`${sourceUrl}/${org.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
title={`View on ${sourceLabel}`}
|
||||
>
|
||||
<SiGithub className="h-4 w-4 mr-2" />
|
||||
GitHub
|
||||
<SourceIcon className="h-4 w-4 mr-2" />
|
||||
{sourceShortLabel}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useAuth } from './useAuth';
|
||||
import { apiRequest } from '@/lib/utils';
|
||||
import type { ConfigApiResponse } from '@/types/config';
|
||||
import {
|
||||
DEFAULT_SOURCE_PROVIDER,
|
||||
normalizeSourceProviderKind,
|
||||
type SourceProviderKind,
|
||||
} from '@/lib/source-providers/kinds';
|
||||
|
||||
interface ConfigStatus {
|
||||
isGitHubConfigured: boolean;
|
||||
@@ -11,6 +16,7 @@ interface ConfigStatus {
|
||||
error: string | null;
|
||||
autoMirrorStarred: boolean;
|
||||
githubOwner: string;
|
||||
sourceProvider: SourceProviderKind;
|
||||
}
|
||||
|
||||
// Cache to prevent duplicate API calls across components
|
||||
@@ -37,6 +43,7 @@ export function useConfigStatus(): ConfigStatus {
|
||||
error: null,
|
||||
autoMirrorStarred: false,
|
||||
githubOwner: '',
|
||||
sourceProvider: DEFAULT_SOURCE_PROVIDER,
|
||||
});
|
||||
|
||||
// Track if this hook has already checked config to prevent multiple calls
|
||||
@@ -52,6 +59,7 @@ export function useConfigStatus(): ConfigStatus {
|
||||
error: 'No user found',
|
||||
autoMirrorStarred: false,
|
||||
githubOwner: '',
|
||||
sourceProvider: DEFAULT_SOURCE_PROVIDER,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -88,6 +96,7 @@ export function useConfigStatus(): ConfigStatus {
|
||||
error: null,
|
||||
autoMirrorStarred: configResponse?.advancedOptions?.autoMirrorStarred ?? false,
|
||||
githubOwner: configResponse?.githubConfig?.username ?? '',
|
||||
sourceProvider: normalizeSourceProviderKind(configResponse?.githubConfig?.provider),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -133,6 +142,7 @@ export function useConfigStatus(): ConfigStatus {
|
||||
error: null,
|
||||
autoMirrorStarred: configResponse?.advancedOptions?.autoMirrorStarred ?? false,
|
||||
githubOwner: configResponse?.githubConfig?.username ?? '',
|
||||
sourceProvider: normalizeSourceProviderKind(configResponse?.githubConfig?.provider),
|
||||
});
|
||||
|
||||
hasCheckedRef.current = true;
|
||||
@@ -145,6 +155,7 @@ export function useConfigStatus(): ConfigStatus {
|
||||
error: error instanceof Error ? error.message : 'Failed to check configuration',
|
||||
autoMirrorStarred: false,
|
||||
githubOwner: '',
|
||||
sourceProvider: DEFAULT_SOURCE_PROVIDER,
|
||||
});
|
||||
hasCheckedRef.current = true;
|
||||
}
|
||||
|
||||
@@ -191,6 +191,34 @@ describe("GiteaSourceProvider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("getOrganization reads an org with its repo count and returns null on 404", async () => {
|
||||
installFetch((url) => {
|
||||
if (url.endsWith("/api/v1/orgs/acme")) return json({ username: "acme", avatar_url: "https://a/acme.png" });
|
||||
if (url.includes("/api/v1/orgs/acme/repos?limit=1")) return json([{}], { headers: { "x-total-count": "7" } });
|
||||
if (url.endsWith("/api/v1/orgs/missing")) return json({ message: "404" }, { status: 404 });
|
||||
return json({ message: "unexpected" }, { status: 500 });
|
||||
});
|
||||
const provider = new GiteaSourceProvider(connection);
|
||||
|
||||
const org = await provider.getOrganization("acme");
|
||||
expect(org).toEqual(expect.objectContaining({ name: "acme", repositoryCount: 7 }));
|
||||
expect(await provider.getOrganization("missing")).toBeNull();
|
||||
});
|
||||
|
||||
test("listOrganizationRepositories pages through the organization's repositories", async () => {
|
||||
installFetch((url) => {
|
||||
if (url.endsWith("&page=1")) return json([repo({ name: "tool", owner: "acme" })], { headers: { "x-total-count": "1" } });
|
||||
return json({ message: "unexpected" }, { status: 500 });
|
||||
});
|
||||
|
||||
const repos = await new GiteaSourceProvider(connection).listOrganizationRepositories("acme");
|
||||
|
||||
expect(calls[0].url).toBe("https://codeberg.org/api/v1/orgs/acme/repos?limit=50&page=1");
|
||||
expect(repos.map((r) => r.fullName)).toEqual(["acme/tool"]);
|
||||
expect(repos[0].organization).toBe("acme");
|
||||
expect(repos[0].sourceProvider).toBe("gitea");
|
||||
});
|
||||
|
||||
test("testConnection reads /user", async () => {
|
||||
installFetch(() => json({ login: "me", full_name: "Me", avatar_url: "https://a/me.png" }));
|
||||
const account = await new GiteaSourceProvider(connection).testConnection();
|
||||
|
||||
@@ -217,6 +217,43 @@ describe("GitLabSourceProvider", () => {
|
||||
expect(failedOrgs).toEqual([]);
|
||||
});
|
||||
|
||||
test("getOrganization reads a group by path and counts its projects, null on 404", async () => {
|
||||
installFetch((url) => {
|
||||
if (url.endsWith("/api/v4/groups/acme")) return json({ id: 7, full_path: "acme" });
|
||||
if (url.includes("/api/v4/groups/7/projects?")) return json([{}], { headers: { "x-total": "12" } });
|
||||
if (url.endsWith("/api/v4/groups/missing")) return json({ message: "404 Not Found" }, { status: 404 });
|
||||
return json({ message: "unexpected" }, { status: 500 });
|
||||
});
|
||||
const provider = new GitLabSourceProvider(connection);
|
||||
|
||||
const org = await provider.getOrganization("acme");
|
||||
expect(org?.name).toBe("acme");
|
||||
expect(org?.repositoryCount).toBe(12);
|
||||
expect(await provider.getOrganization("missing")).toBeNull();
|
||||
});
|
||||
|
||||
test("listOrganizationRepositories includes subgroups and flattens them onto the group", async () => {
|
||||
installFetch((url) => {
|
||||
if (url.includes("/api/v4/groups/acme/projects?")) {
|
||||
return json([
|
||||
project({
|
||||
path: "tool",
|
||||
path_with_namespace: "acme/tools/tool",
|
||||
namespace: { kind: "group", path: "tools", full_path: "acme/tools" },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return json({ message: "unexpected" }, { status: 500 });
|
||||
});
|
||||
|
||||
const repos = await new GitLabSourceProvider(connection).listOrganizationRepositories("acme");
|
||||
|
||||
expect(calls[0].url).toContain("include_subgroups=true");
|
||||
expect(repos.map((r) => r.fullName)).toEqual(["acme/tools/tool"]);
|
||||
expect(repos[0].organization).toBe("acme");
|
||||
expect(repos[0].sourceProvider).toBe("gitlab");
|
||||
});
|
||||
|
||||
test("isRepositoryStarred matches the full path among starred search results", async () => {
|
||||
installFetch(() =>
|
||||
json([project({ path_with_namespace: "other/widget" }), project({ path_with_namespace: "Acme/Widget" })])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
isBetaSourceProvider,
|
||||
isValidSourceOrgName,
|
||||
SOURCE_PROVIDER_DEFAULT_URLS,
|
||||
getRepositorySource,
|
||||
isRepositoryFromConfiguredSource,
|
||||
@@ -19,6 +20,21 @@ describe("isBetaSourceProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidSourceOrgName", () => {
|
||||
test("accepts plain org and group names", () => {
|
||||
expect(isValidSourceOrgName("acme")).toBe(true);
|
||||
expect(isValidSourceOrgName(" acme ")).toBe(true);
|
||||
expect(isValidSourceOrgName("my-org")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects nested paths and empty names", () => {
|
||||
expect(isValidSourceOrgName("acme/tools")).toBe(false);
|
||||
expect(isValidSourceOrgName("group/subgroup/deep")).toBe(false);
|
||||
expect(isValidSourceOrgName("/acme")).toBe(false);
|
||||
expect(isValidSourceOrgName(" ")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeSourceProviderKind", () => {
|
||||
test("accepts the three kinds and defaults everything else to GitHub", () => {
|
||||
expect(normalizeSourceProviderKind("github")).toBe("github");
|
||||
|
||||
@@ -43,6 +43,16 @@ export function isSourceProviderKind(value: unknown): value is SourceProviderKin
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Org names are a single path segment on every source. GitLab nests groups,
|
||||
* but projects flatten onto the top level group, so a nested name would
|
||||
* import an organization whose repositories never match it.
|
||||
*/
|
||||
export function isValidSourceOrgName(name: string): boolean {
|
||||
const trimmed = name.trim();
|
||||
return trimmed.length > 0 && !trimmed.includes("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce an untrusted value to a provider kind, defaulting to GitHub.
|
||||
* "forgejo" and "codeberg" are accepted as spellings of the Gitea kind.
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from "@/types/organizations";
|
||||
import type { RepoStatus } from "@/types/Repository";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { createSourceProviderFromConfig } from "@/lib/source-providers";
|
||||
import { createSourceProviderFromConfig, isValidSourceOrgName } from "@/lib/source-providers";
|
||||
import { normalizeGitRepoToInsert, calcBatchSizeForInsert } from "@/lib/repo-utils";
|
||||
import { resolveOrganizationSkipForks } from "@/lib/utils/mirror-overrides";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
@@ -32,6 +32,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const trimmedOrg = org.trim();
|
||||
const normalizedOrg = trimmedOrg.toLowerCase();
|
||||
|
||||
if (!isValidSourceOrgName(trimmedOrg)) {
|
||||
return jsonResponse({
|
||||
data: {
|
||||
success: false,
|
||||
error:
|
||||
"Organization names cannot contain '/'. Add the top level group; nested groups flatten onto it.",
|
||||
},
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if org already exists (case-insensitive)
|
||||
const [existingOrg] = await db
|
||||
.select()
|
||||
|
||||
Reference in new issue
Block a user