mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-08 12:36:44 +03:00
feat: add organization destination update API and enhance organization list editing functionality
This commit is contained in:
@@ -97,6 +97,11 @@ export function ConfigTabs() {
|
|||||||
return isGitHubValid && isGiteaValid;
|
return isGitHubValid && isGiteaValid;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isGitHubConfigValid = (): boolean => {
|
||||||
|
const { githubConfig } = config;
|
||||||
|
return !!(githubConfig.username.trim() && githubConfig.token.trim());
|
||||||
|
};
|
||||||
|
|
||||||
// Removed the problematic useEffect that was causing circular dependencies
|
// Removed the problematic useEffect that was causing circular dependencies
|
||||||
// The lastRun and nextRun should be managed by the backend and fetched via API
|
// The lastRun and nextRun should be managed by the backend and fetched via API
|
||||||
|
|
||||||
@@ -571,10 +576,10 @@ export function ConfigTabs() {
|
|||||||
<div className="flex gap-x-4">
|
<div className="flex gap-x-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleImportGitHubData}
|
onClick={handleImportGitHubData}
|
||||||
disabled={isSyncing || !isConfigFormValid()}
|
disabled={isSyncing || !isGitHubConfigValid()}
|
||||||
title={
|
title={
|
||||||
!isConfigFormValid()
|
!isGitHubConfigValid()
|
||||||
? 'Please fill all required GitHub and Gitea fields'
|
? 'Please fill GitHub username and token fields'
|
||||||
: isSyncing
|
: isSyncing
|
||||||
? 'Import in progress'
|
? 'Import in progress'
|
||||||
: 'Import GitHub Data'
|
: 'Import GitHub Data'
|
||||||
|
|||||||
@@ -404,6 +404,7 @@ export function Organization() {
|
|||||||
loadingOrgIds={loadingOrgIds}
|
loadingOrgIds={loadingOrgIds}
|
||||||
onMirror={handleMirrorOrg}
|
onMirror={handleMirrorOrg}
|
||||||
onAddOrganization={() => setIsDialogOpen(true)}
|
onAddOrganization={() => setIsDialogOpen(true)}
|
||||||
|
onRefresh={() => fetchOrganizations(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddOrganizationDialog
|
<AddOrganizationDialog
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, Settings, ArrowRight } from "lucide-react";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, Settings, ArrowRight, Edit3, X, Save } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { SiGithub } from "react-icons/si";
|
import { SiGithub } from "react-icons/si";
|
||||||
import type { Organization } from "@/lib/db/schema";
|
import type { Organization } from "@/lib/db/schema";
|
||||||
import type { FilterParams } from "@/types/filter";
|
import type { FilterParams } from "@/types/filter";
|
||||||
@@ -19,6 +21,7 @@ interface OrganizationListProps {
|
|||||||
onMirror: ({ orgId }: { orgId: string }) => Promise<void>;
|
onMirror: ({ orgId }: { orgId: string }) => Promise<void>;
|
||||||
loadingOrgIds: Set<string>;
|
loadingOrgIds: Set<string>;
|
||||||
onAddOrganization?: () => void;
|
onAddOrganization?: () => void;
|
||||||
|
onRefresh?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get status badge variant and icon
|
// Helper function to get status badge variant and icon
|
||||||
@@ -45,7 +48,62 @@ export function OrganizationList({
|
|||||||
onMirror,
|
onMirror,
|
||||||
loadingOrgIds,
|
loadingOrgIds,
|
||||||
onAddOrganization,
|
onAddOrganization,
|
||||||
|
onRefresh,
|
||||||
}: OrganizationListProps) {
|
}: OrganizationListProps) {
|
||||||
|
const [editingOrg, setEditingOrg] = useState<string | null>(null);
|
||||||
|
const [editValue, setEditValue] = useState<string>("");
|
||||||
|
const [isUpdating, setIsUpdating] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleEditStart = (orgId: string, currentDestination?: string) => {
|
||||||
|
setEditingOrg(orgId);
|
||||||
|
setEditValue(currentDestination || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setEditingOrg(null);
|
||||||
|
setEditValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async (orgId: string) => {
|
||||||
|
setIsUpdating(orgId);
|
||||||
|
try {
|
||||||
|
// Call API to update organization destination
|
||||||
|
const response = await fetch(`/api/organizations/${orgId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
destinationOrg: editValue.trim() || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || "Failed to update organization");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Close edit mode
|
||||||
|
setEditingOrg(null);
|
||||||
|
setEditValue("");
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
const destination = result.destinationOrg || "default";
|
||||||
|
toast.success(`Organization destination updated to: ${destination}`);
|
||||||
|
|
||||||
|
// Refresh organizations data
|
||||||
|
if (onRefresh) {
|
||||||
|
await onRefresh();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating organization:", error);
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to update organization");
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
const hasAnyFilter = Object.values(filter).some(
|
const hasAnyFilter = Object.values(filter).some(
|
||||||
(val) => val?.toString().trim() !== ""
|
(val) => val?.toString().trim() !== ""
|
||||||
);
|
);
|
||||||
@@ -141,13 +199,68 @@ export function OrganizationList({
|
|||||||
{org.membershipRole}
|
{org.membershipRole}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Show destination override if configured */}
|
|
||||||
{org.destinationOrg && (
|
{/* Destination override section */}
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
|
<div className="mt-2">
|
||||||
<ArrowRight className="h-3 w-3" />
|
{editingOrg === org.id ? (
|
||||||
<span>Mirrors to: <span className="font-medium">{org.destinationOrg}</span></span>
|
<div className="space-y-2">
|
||||||
</div>
|
<Label htmlFor={`dest-${org.id}`} className="text-xs text-muted-foreground">
|
||||||
)}
|
Mirror destination (leave empty for default: {org.name})
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id={`dest-${org.id}`}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
placeholder={org.name}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={isUpdating === org.id}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleEditSave(org.id!)}
|
||||||
|
disabled={isUpdating === org.id}
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<Save className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleEditCancel}
|
||||||
|
disabled={isUpdating === org.id}
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<ArrowRight className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
Mirrors to: <span className="font-medium">
|
||||||
|
{org.destinationOrg || org.name}
|
||||||
|
</span>
|
||||||
|
{org.destinationOrg && (
|
||||||
|
<span className="text-orange-600 ml-1">(override)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleEditStart(org.id!, org.destinationOrg)}
|
||||||
|
className="h-6 w-6 p-0 opacity-60 hover:opacity-100"
|
||||||
|
title="Edit destination"
|
||||||
|
>
|
||||||
|
<Edit3 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={statusBadge.variant} className="ml-2">
|
<Badge variant={statusBadge.variant} className="ml-2">
|
||||||
{StatusIcon && <StatusIcon className={cn(
|
{StatusIcon && <StatusIcon className={cn(
|
||||||
|
|||||||
82
src/pages/api/organizations/[id].ts
Normal file
82
src/pages/api/organizations/[id].ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db, organizations } from "@/lib/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { createSecureErrorResponse } from "@/lib/utils";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
|
||||||
|
|
||||||
|
export const PATCH: APIRoute = async ({ request, params, cookies }) => {
|
||||||
|
try {
|
||||||
|
// Get token from Authorization header or cookies
|
||||||
|
const authHeader = request.headers.get("Authorization");
|
||||||
|
const token = authHeader?.split(" ")[1] || cookies.get("token")?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token and get user ID
|
||||||
|
let userId: string;
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
|
||||||
|
userId = decoded.id;
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({ error: "Invalid token" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = params.id;
|
||||||
|
if (!orgId) {
|
||||||
|
return new Response(JSON.stringify({ error: "Organization ID is required" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { destinationOrg } = body;
|
||||||
|
|
||||||
|
// Validate that the organization belongs to the user
|
||||||
|
const [existingOrg] = await db
|
||||||
|
.select()
|
||||||
|
.from(organizations)
|
||||||
|
.where(and(eq(organizations.id, orgId), eq(organizations.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingOrg) {
|
||||||
|
return new Response(JSON.stringify({ error: "Organization not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the organization's destination override
|
||||||
|
await db
|
||||||
|
.update(organizations)
|
||||||
|
.set({
|
||||||
|
destinationOrg: destinationOrg || null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(organizations.id, orgId));
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: "Organization destination updated successfully",
|
||||||
|
destinationOrg: destinationOrg || null,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return createSecureErrorResponse(error, "Update organization destination", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user