Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 63 additions & 3 deletions src/OpenDeepWiki/Endpoints/OrganizationEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,77 @@ public static IEndpointRouteBuilder MapOrganizationEndpoints(this IEndpointRoute
// 获取当前用户部门下的仓库列表
group.MapGet("/my-repositories", async (
ClaimsPrincipal user,
[FromServices] IOrganizationService orgService) =>
[FromServices] IOrganizationService orgService,
[FromQuery] bool? includeRestricted) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
return Results.Unauthorized();

var result = await orgService.GetDepartmentRepositoriesAsync(userId);
var result = await orgService.GetDepartmentRepositoriesAsync(userId, includeRestricted ?? false);
return Results.Ok(new { success = true, data = result });
})
.WithName("GetMyDepartmentRepositories")
.WithSummary("获取当前用户部门下的仓库列表");
.WithSummary("Get repository list for current user's departments");

// Share a repository with current user's departments
group.MapPost("/my-repositories/{repositoryId}/share", async (
string repositoryId,
ClaimsPrincipal user,
[FromServices] IOrganizationService orgService) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
return Results.Unauthorized();

var result = await orgService.ShareRepositoryWithMyDepartmentsAsync(userId, repositoryId);
return Results.Ok(new { success = result });
})
.WithName("ShareRepositoryWithMyDepartments")
.WithSummary("Share a repository with current user's departments");

// Unshare a repository from current user's departments
group.MapDelete("/my-repositories/{repositoryId}/share", async (
string repositoryId,
ClaimsPrincipal user,
[FromServices] IOrganizationService orgService) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
return Results.Unauthorized();

var result = await orgService.UnshareRepositoryFromMyDepartmentsAsync(userId, repositoryId);
return Results.Ok(new { success = result });
})
.WithName("UnshareRepositoryFromMyDepartments")
.WithSummary("Unshare a repository from current user's departments");

// Admin-only endpoints for repository restriction
var adminGroup = group.MapGroup("/repositories").RequireAuthorization("AdminOnly");

adminGroup.MapPost("/{repositoryId}/restrict", async (
string repositoryId,
ClaimsPrincipal user,
[FromServices] IOrganizationService orgService) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();

var result = await orgService.RestrictRepositoryInDepartmentsAsync(repositoryId, userId);
return result ? Results.Ok(new { success = true }) : Results.BadRequest(new { success = false });
}).WithName("RestrictRepository");

adminGroup.MapPost("/{repositoryId}/unrestrict", async (
string repositoryId,
ClaimsPrincipal user,
[FromServices] IOrganizationService orgService) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();

var result = await orgService.UnrestrictRepositoryInDepartmentsAsync(repositoryId, userId);
return result ? Results.Ok(new { success = true }) : Results.BadRequest(new { success = false });
}).WithName("UnrestrictRepository");

return app;
}
Expand Down
14 changes: 13 additions & 1 deletion src/OpenDeepWiki/Services/Organizations/IOrganizationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ namespace OpenDeepWiki.Services.Organizations;
public interface IOrganizationService
{
Task<List<UserDepartmentInfo>> GetUserDepartmentsAsync(string userId);
Task<List<DepartmentRepositoryInfo>> GetDepartmentRepositoriesAsync(string userId);
Task<List<DepartmentRepositoryInfo>> GetDepartmentRepositoriesAsync(string userId, bool includeRestricted = false);
Task<bool> ShareRepositoryWithMyDepartmentsAsync(string userId, string repositoryId);
Task<bool> UnshareRepositoryFromMyDepartmentsAsync(string userId, string repositoryId);
Task<bool> RestrictRepositoryInDepartmentsAsync(string repositoryId, string adminUserId);
Task<bool> UnrestrictRepositoryInDepartmentsAsync(string repositoryId, string adminUserId);
}

/// <summary>
Expand All @@ -35,4 +39,12 @@ public class DepartmentRepositoryInfo
public string StatusName { get; set; } = string.Empty;
public string DepartmentId { get; set; } = string.Empty;
public string DepartmentName { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public string? PrimaryLanguage { get; set; }
public bool IsRestricted { get; set; }
/// <summary>
/// Whether the repository is publicly accessible. Used by frontend to show
/// dual icons (public + org) for repos that are both public and department-assigned.
/// </summary>
public bool IsPublic { get; set; }
}
147 changes: 141 additions & 6 deletions src/OpenDeepWiki/Services/Organizations/OrganizationService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using OpenDeepWiki.EFCore;
using OpenDeepWiki.Entities;

namespace OpenDeepWiki.Services.Organizations;

Expand Down Expand Up @@ -37,7 +38,7 @@ public async Task<List<UserDepartmentInfo>> GetUserDepartmentsAsync(string userI
}).ToList();
}

public async Task<List<DepartmentRepositoryInfo>> GetDepartmentRepositoriesAsync(string userId)
public async Task<List<DepartmentRepositoryInfo>> GetDepartmentRepositoriesAsync(string userId, bool includeRestricted = false)
{
// 获取用户所属的部门
var userDeptIds = await _context.UserDepartments
Expand All @@ -53,10 +54,14 @@ public async Task<List<DepartmentRepositoryInfo>> GetDepartmentRepositoriesAsync
.Where(d => userDeptIds.Contains(d.Id) && d.IsActive)
.ToDictionaryAsync(d => d.Id);

// 获取这些部门分配的仓库
var assignments = await _context.RepositoryAssignments
.Where(ra => userDeptIds.Contains(ra.DepartmentId) && !ra.IsDeleted)
.ToListAsync();
// Get repositories assigned to these departments
var assignmentsQuery = _context.RepositoryAssignments
.Where(ra => userDeptIds.Contains(ra.DepartmentId));

if (!includeRestricted)
assignmentsQuery = assignmentsQuery.Where(ra => !ra.IsDeleted);

var assignments = await assignmentsQuery.ToListAsync();

var repoIds = assignments.Select(a => a.RepositoryId).Distinct().ToList();
var repos = await _context.Repositories
Expand All @@ -74,12 +79,142 @@ public async Task<List<DepartmentRepositoryInfo>> GetDepartmentRepositoriesAsync
Status = (int)repos[a.RepositoryId].Status,
StatusName = GetStatusName((int)repos[a.RepositoryId].Status),
DepartmentId = a.DepartmentId,
DepartmentName = depts[a.DepartmentId].Name
DepartmentName = depts[a.DepartmentId].Name,
CreatedAt = repos[a.RepositoryId].CreatedAt,
PrimaryLanguage = repos[a.RepositoryId].PrimaryLanguage,
IsRestricted = a.IsDeleted,
IsPublic = repos[a.RepositoryId].IsPublic
})
.DistinctBy(r => r.RepositoryId)
.ToList();
}

public async Task<bool> ShareRepositoryWithMyDepartmentsAsync(string userId, string repositoryId)
{
var repo = await _context.Repositories.FirstOrDefaultAsync(r => r.Id == repositoryId && !r.IsDeleted);
if (repo == null || repo.OwnerUserId != userId) return false;

var userDeptIds = await _context.UserDepartments
.Where(ud => ud.UserId == userId && !ud.IsDeleted)
.Select(ud => ud.DepartmentId)
.ToListAsync();

if (userDeptIds.Count == 0) return false;

// Get ALL existing assignments (including soft-deleted)
var existingAssignments = await _context.RepositoryAssignments
.Where(ra => ra.RepositoryId == repositoryId && userDeptIds.Contains(ra.DepartmentId))
.ToListAsync();

foreach (var deptId in userDeptIds)
{
var existing = existingAssignments.FirstOrDefault(a => a.DepartmentId == deptId);
if (existing != null)
{
// Un-soft-delete if it was restricted
if (existing.IsDeleted)
{
existing.IsDeleted = false;
existing.DeletedAt = null;
}
}
else
{
_context.RepositoryAssignments.Add(new RepositoryAssignment
{
Id = Guid.NewGuid().ToString("N"),
RepositoryId = repositoryId,
DepartmentId = deptId,
AssigneeUserId = userId
});
}
}

await _context.SaveChangesAsync();
return true;
}

public async Task<bool> UnshareRepositoryFromMyDepartmentsAsync(string userId, string repositoryId)
{
// 1. Verify user owns the repository
var repo = await _context.Repositories.FirstOrDefaultAsync(r => r.Id == repositoryId && !r.IsDeleted);
if (repo == null || repo.OwnerUserId != userId) return false;

// 2. Get user's departments
var userDeptIds = await _context.UserDepartments
.Where(ud => ud.UserId == userId && !ud.IsDeleted)
.Select(ud => ud.DepartmentId)
.ToListAsync();

// 3. Soft-delete assignments for user's departments
var assignments = await _context.RepositoryAssignments
.Where(ra => ra.RepositoryId == repositoryId && userDeptIds.Contains(ra.DepartmentId) && !ra.IsDeleted)
.ToListAsync();

foreach (var assignment in assignments)
{
assignment.IsDeleted = true;
assignment.DeletedAt = DateTime.UtcNow;
}

await _context.SaveChangesAsync();
return true;
}

public async Task<bool> RestrictRepositoryInDepartmentsAsync(string repositoryId, string adminUserId)
{
// Get admin's departments
var adminDeptIds = await _context.UserDepartments
.Where(ud => ud.UserId == adminUserId && !ud.IsDeleted)
.Select(ud => ud.DepartmentId)
.ToListAsync();

if (adminDeptIds.Count == 0) return false;

// Soft-delete active assignments for these departments
var assignments = await _context.RepositoryAssignments
.Where(ra => ra.RepositoryId == repositoryId && adminDeptIds.Contains(ra.DepartmentId) && !ra.IsDeleted)
.ToListAsync();

if (assignments.Count == 0) return false;

foreach (var assignment in assignments)
{
assignment.IsDeleted = true;
assignment.DeletedAt = DateTime.UtcNow;
}

await _context.SaveChangesAsync();
return true;
}

public async Task<bool> UnrestrictRepositoryInDepartmentsAsync(string repositoryId, string adminUserId)
{
// Get admin's departments
var adminDeptIds = await _context.UserDepartments
.Where(ud => ud.UserId == adminUserId && !ud.IsDeleted)
.Select(ud => ud.DepartmentId)
.ToListAsync();

if (adminDeptIds.Count == 0) return false;

// Un-soft-delete restricted assignments for these departments
var assignments = await _context.RepositoryAssignments
.Where(ra => ra.RepositoryId == repositoryId && adminDeptIds.Contains(ra.DepartmentId) && ra.IsDeleted)
.ToListAsync();

if (assignments.Count == 0) return false;

foreach (var assignment in assignments)
{
assignment.IsDeleted = false;
assignment.DeletedAt = null;
}

await _context.SaveChangesAsync();
return true;
}

private static string GetStatusName(int status) => status switch
{
0 => "Pending",
Expand Down
5 changes: 3 additions & 2 deletions src/OpenDeepWiki/Services/Repositories/RepositoryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
using OpenDeepWiki.Entities;
using OpenDeepWiki.Models;
using OpenDeepWiki.Services.Auth;
using OpenDeepWiki.Services.GitHub;

namespace OpenDeepWiki.Services.Repositories;

[MiniApi(Route = "/api/v1/repositories")]
[Tags("仓库")]
public class RepositoryService(IContext context, IGitPlatformService gitPlatformService, IUserContext userContext)
[Tags("Repository")]
public class RepositoryService(IContext context, IGitPlatformService gitPlatformService, IUserContext userContext, IGitHubAppService gitHubAppService)
{
[HttpPost("/submit")]
public async Task<Repository> SubmitAsync([FromBody] RepositorySubmitRequest request)
Expand Down
45 changes: 38 additions & 7 deletions web/app/(main)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useState, useCallback, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { AppLayout } from "@/components/app-layout";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
Expand All @@ -16,13 +16,21 @@ import {
import { useAuth } from "@/contexts/auth-context";
import { useScrollPosition } from "@/hooks/use-scroll-position";
import { PublicRepositoryList } from "@/components/repo/public-repository-list";
import type { RepositoryView } from "@/components/repo/public-repository-list";
import { cn } from "@/lib/utils";

export default function Home() {
function HomeContent() {
const t = useTranslations();
const router = useRouter();
const searchParams = useSearchParams();
const { user } = useAuth();
const [activeItem, setActiveItem] = useState(t("sidebar.explore"));

const viewParam = (searchParams.get("view") as RepositoryView) || "public";

const activeItem = (viewParam === "organization" || viewParam === "mine")
? t("sidebar.private")
: t("sidebar.explore");

const [isFormOpen, setIsFormOpen] = useState(false);
const [isIntegrationsOpen, setIsIntegrationsOpen] = useState(false);
const [keyword, setKeyword] = useState("");
Expand All @@ -40,10 +48,21 @@ export default function Home() {
setIsFormOpen(true);
}, [user, router]);

const handleViewChange = useCallback((newView: RepositoryView) => {
const params = new URLSearchParams(searchParams.toString());
if (newView === "public") {
params.delete("view");
} else {
params.set("view", newView);
}
const query = params.toString();
router.replace(query ? `/?${query}` : "/", { scroll: false });
}, [router, searchParams]);

return (
<AppLayout
activeItem={activeItem}
onItemClick={setActiveItem}
onItemClick={() => {}}
searchBox={{
value: keyword,
onChange: setKeyword,
Expand Down Expand Up @@ -118,11 +137,23 @@ export default function Home() {
</div>
</div>

{/* Public Repository List Section */}
{/* Repository List Section */}
<div className="w-full max-w-6xl mx-auto mt-8">
<PublicRepositoryList keyword={keyword} />
<PublicRepositoryList
keyword={keyword}
view={viewParam}
onViewChange={handleViewChange}
/>
</div>
</div>
</AppLayout>
);
}

export default function Home() {
return (
<Suspense fallback={null}>
<HomeContent />
</Suspense>
);
}
2 changes: 1 addition & 1 deletion web/app/(main)/private/github-import/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export default function UserGitHubImportPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/private">
<Link href="/?view=organization">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
{t("home.githubImport.backToPrivate")}
Expand Down
Loading