Compare commits

...

3 Commits

14 changed files with 181 additions and 163 deletions
+2 -2
View File
@@ -294,7 +294,7 @@ func (s *Store) ListProjectGroupRoles(projectID string) ([]models.ProjectGroupRo
var items []models.ProjectGroupRole var items []models.ProjectGroupRole
var item models.ProjectGroupRole var item models.ProjectGroupRole
rows, err = s.Query(`SELECT p.public_id, g.public_id, b.role, b.created_at rows, err = s.Query(`SELECT p.public_id, g.public_id, g.name, b.role, b.created_at
FROM project_role_bindings b FROM project_role_bindings b
JOIN projects p ON p.id = b.project_id JOIN projects p ON p.id = b.project_id
JOIN user_groups g ON g.id = b.subject_id JOIN user_groups g ON g.id = b.subject_id
@@ -304,7 +304,7 @@ func (s *Store) ListProjectGroupRoles(projectID string) ([]models.ProjectGroupRo
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
err = rows.Scan(&item.ProjectID, &item.GroupID, &item.Role, &item.CreatedAt) err = rows.Scan(&item.ProjectID, &item.GroupID, &item.GroupName, &item.Role, &item.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+7 -17
View File
@@ -20,34 +20,24 @@ func (s *Store) GetProjectSummary(projectID string) (models.ProjectSummary, erro
return summary, err return summary, err
} }
// Repos = local repos plus foreign repos attached to this project (matching // Repos count only owned repositories. Foreign attachments are references
// the repo list). Members counts user role bindings (group roles are listed // shown in the repository list, not project-owned repository resources.
// separately on the page).
err = s.QueryRow(`SELECT err = s.QueryRow(`SELECT
(SELECT COUNT(*) FROM repos WHERE project_id = ?) (SELECT COUNT(*) FROM repos WHERE project_id = ?),
+ (SELECT COUNT(*) FROM project_repos pr JOIN repos r ON r.id = pr.repo_id WHERE pr.project_id = ? AND r.project_id <> ? AND r.attachable = 1),
(SELECT COUNT(*) FROM boards WHERE project_id = ? AND delete_at = 0), (SELECT COUNT(*) FROM boards WHERE project_id = ? AND delete_at = 0),
(SELECT COUNT(*) FROM issues WHERE project_id = ?), (SELECT COUNT(*) FROM issues WHERE project_id = ?),
(SELECT COUNT(*) FROM wiki_pages WHERE project_id = ?), (SELECT COUNT(*) FROM wiki_pages WHERE project_id = ?),
(SELECT COUNT(*) FROM project_role_bindings WHERE project_id = ? AND subject_type = 'user')`, (SELECT COUNT(*) FROM project_role_bindings WHERE project_id = ? AND subject_type = 'user')`,
pid, pid, pid, pid, pid, pid, pid).Scan( pid, pid, pid, pid, pid).Scan(
&summary.Counts.Repos, &summary.Counts.Boards, &summary.Counts.Issues, &summary.Counts.Wiki, &summary.Counts.Members) &summary.Counts.Repos, &summary.Counts.Boards, &summary.Counts.Issues, &summary.Counts.Wiki, &summary.Counts.Members)
if err != nil { if err != nil {
return summary, err return summary, err
} }
rows, err = s.Query(`SELECT public_id, name, type, created_at rows, err = s.Query(`SELECT public_id, name, type, created_at
FROM ( FROM repos
SELECT r.public_id, r.name, r.type, r.created_at WHERE project_id = ?
FROM repos r ORDER BY created_at DESC, name LIMIT ?`, pid, projectSummaryRecentLimit)
WHERE r.project_id = ?
UNION ALL
SELECT r.public_id, r.name, r.type, r.created_at
FROM project_repos pr
JOIN repos r ON r.id = pr.repo_id
WHERE pr.project_id = ? AND r.project_id <> ? AND r.attachable = 1
)
ORDER BY created_at DESC, name LIMIT ?`, pid, pid, pid, projectSummaryRecentLimit)
if err != nil { if err != nil {
return summary, err return summary, err
} }
+2 -10
View File
@@ -536,16 +536,8 @@ func (s *Store) ListProjectRPMMirrorStatus(projectID string) ([]models.RPMMirror
JOIN repos r ON r.id = d.repo_id JOIN repos r ON r.id = d.repo_id
JOIN projects p ON p.id = r.project_id JOIN projects p ON p.id = r.project_id
WHERE d.mode = 'mirror' WHERE d.mode = 'mirror'
AND ( AND r.project_id = (SELECT id FROM projects WHERE public_id = ?)
r.project_id = (SELECT id FROM projects WHERE public_id = ?) ORDER BY d.sync_running DESC, d.updated_at DESC, p.slug, r.name, d.path`, projectID)
OR EXISTS (
SELECT 1
FROM project_repos pr
JOIN projects pp ON pp.id = pr.project_id
WHERE pr.repo_id = r.id AND pp.public_id = ? AND r.attachable = 1
)
)
ORDER BY d.sync_running DESC, d.updated_at DESC, p.slug, r.name, d.path`, projectID, projectID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+9 -52
View File
@@ -1220,7 +1220,14 @@ func (s *Store) ListProjectMembers(projectID string) ([]models.ProjectMember, er
var members []models.ProjectMember var members []models.ProjectMember
var m models.ProjectMember var m models.ProjectMember
rows, err = s.Query(`SELECT p.public_id, u.public_id, b.role, b.created_at rows, err = s.Query(`SELECT p.public_id,
u.public_id,
u.username,
u.display_name,
u.email,
CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END,
b.role,
b.created_at
FROM project_role_bindings b FROM project_role_bindings b
JOIN projects p ON p.id = b.project_id JOIN projects p ON p.id = b.project_id
JOIN users u ON u.id = b.subject_id JOIN users u ON u.id = b.subject_id
@@ -1232,7 +1239,7 @@ func (s *Store) ListProjectMembers(projectID string) ([]models.ProjectMember, er
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
err = rows.Scan(&m.ProjectID, &m.UserID, &m.Role, &m.CreatedAt) err = rows.Scan(&m.ProjectID, &m.UserID, &m.Username, &m.DisplayName, &m.Email, &m.AvatarURL, &m.Role, &m.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1548,56 +1555,6 @@ func (s *Store) DetachRepoFromProject(projectID string, repoID string) error {
return err return err
} }
func (s *Store) GetRepoProjectIDs(repoID string) ([]string, error) {
var rows *sql.Rows
var err error
var ids []string
var id string
rows, err = s.Query(`
SELECT p.public_id FROM repos r JOIN projects p ON p.id = r.project_id WHERE r.public_id = ?
UNION
SELECT p.public_id
FROM project_repos pr
JOIN projects p ON p.id = pr.project_id
WHERE pr.repo_id = (SELECT id FROM repos WHERE public_id = ?)
`, repoID, repoID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&id)
if err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (s *Store) GetRepoAttachedProjectIDs(repoID string) ([]string, error) {
var rows *sql.Rows
var err error
var ids []string
var id string
// returns the list of project public IDs that have attached a repository as a foreign repo
rows, err = s.Query(`
SELECT p.public_id
FROM project_repos pr
JOIN projects p ON p.id = pr.project_id
WHERE pr.repo_id = (SELECT id FROM repos WHERE public_id = ?)
`, repoID)
if err != nil { return nil, err }
defer rows.Close()
for rows.Next() {
err = rows.Scan(&id)
if err != nil { return nil, err }
ids = append(ids, id)
}
return ids, rows.Err()
}
func (s *Store) ListAvailableReposForProject(projectID string, query string, limit int, offset int) ([]models.Repo, error) { func (s *Store) ListAvailableReposForProject(projectID string, query string, limit int, offset int) ([]models.Repo, error) {
var rows *sql.Rows var rows *sql.Rows
var err error var err error
-40
View File
@@ -6732,13 +6732,8 @@ func (api *API) requireRepoRole(w http.ResponseWriter, r *http.Request, repoID s
var principal models.ServicePrincipal var principal models.ServicePrincipal
var repo models.Repo var repo models.Repo
var ok bool var ok bool
var projectIDs []string
var role string var role string
var err error var err error
var attachedProjectIDs []string
var attachedProjectIDsLoaded bool
var attachedProjectIDsErr error
var loadAttachedProjectIDs func() ([]string, error)
user, ok = middleware.UserFromContext(r.Context()) user, ok = middleware.UserFromContext(r.Context())
if ok && user.IsAdmin { return true } if ok && user.IsAdmin { return true }
@@ -6748,34 +6743,10 @@ func (api *API) requireRepoRole(w http.ResponseWriter, r *http.Request, repoID s
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
return false return false
} }
loadAttachedProjectIDs = func() ([]string, error) {
if !attachedProjectIDsLoaded {
attachedProjectIDs, attachedProjectIDsErr = api.store(r).GetRepoAttachedProjectIDs(repo.ID)
attachedProjectIDsLoaded = true
}
return attachedProjectIDs, attachedProjectIDsErr
}
if ok { if ok {
// If the user has a role in the repos owner project, normal repo permissions apply.
role, err = api.store(r).GetProjectRoleForUser(repo.ProjectID, user.ID) role, err = api.store(r).GetProjectRoleForUser(repo.ProjectID, user.ID)
if err == nil && roleAllows(role, required) { return true } if err == nil && roleAllows(role, required) { return true }
if repo.Attachable && roleAllows(models.RoleViewer, required) {
// If the user only has a role in one of the attached projects, access is treated as foreign/attached access.
// The following part may look surprosing:
// - Attached project permissions can grant read-only access to the repo, if the repo is marked attachable.
// - So a user with no role in the owner project can still browse the repo through an attaching project.
// The attachable flag is a onwer-controlled flag for read-only sharing.
projectIDs, err = loadAttachedProjectIDs()
if err == nil {
var i int
for i = 0; i < len(projectIDs); i++ {
role, err = api.store(r).GetProjectRoleForUser(projectIDs[i], user.ID)
if err != nil { continue }
if roleAllows(role, models.RoleViewer) { return true }
}
}
}
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
return false return false
} }
@@ -6789,17 +6760,6 @@ func (api *API) requireRepoRole(w http.ResponseWriter, r *http.Request, repoID s
role, err = api.store(r).GetPrincipalProjectRole(principal.ID, repo.ProjectID) role, err = api.store(r).GetPrincipalProjectRole(principal.ID, repo.ProjectID)
if err == nil && roleAllows(role, required) { return true } if err == nil && roleAllows(role, required) { return true }
if repo.Attachable && roleAllows(models.RoleViewer, required) {
projectIDs, err = loadAttachedProjectIDs()
if err == nil {
var i int
for i = 0; i < len(projectIDs); i++ {
role, err = api.store(r).GetPrincipalProjectRole(principal.ID, projectIDs[i])
if err != nil { continue }
if roleAllows(role, models.RoleViewer) { return true }
}
}
}
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
return false return false
} }
+9 -4
View File
@@ -82,15 +82,20 @@ type Project struct {
} }
type ProjectMember struct { type ProjectMember struct {
ProjectID string `json:"project_id"` ProjectID string `json:"project_id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
Role string `json:"role"` Username string `json:"username,omitempty"`
CreatedAt int64 `json:"created_at"` DisplayName string `json:"display_name,omitempty"`
Email string `json:"email,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
Role string `json:"role"`
CreatedAt int64 `json:"created_at"`
} }
type ProjectGroupRole struct { type ProjectGroupRole struct {
ProjectID string `json:"project_id"` ProjectID string `json:"project_id"`
GroupID string `json:"group_id"` GroupID string `json:"group_id"`
GroupName string `json:"group_name,omitempty"`
Role string `json:"role"` Role string `json:"role"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
} }
+2 -2
View File
@@ -482,7 +482,7 @@ Stores repositories belonging to projects.
| `description` | Optional short repository description shown in repository lists. | | `description` | Optional short repository description shown in repository lists. |
| `type` | Repository type: usually `git`, `rpm`, or `docker`. | | `type` | Repository type: usually `git`, `rpm`, or `docker`. |
| `path` | Storage path. | | `path` | Storage path. |
| `attachable` | Boolean flag allowing other projects to attach this repository as read-only foreign content. When off, existing attachments do not grant access. | | `attachable` | Boolean flag allowing other projects to attach this repository as a foreign reference. Attachments do not grant repository access; repository access still uses the origin project permissions. |
| `signing_key_id` | Optional internal FK to `gpg_keys`; used for Git/RPM signing. `NULL` means unsigned. | | `signing_key_id` | Optional internal FK to `gpg_keys`; used for Git/RPM signing. `NULL` means unsigned. |
| `created_by` | Internal user FK. | | `created_by` | Internal user FK. |
| `created_at` | Creation time. | | `created_at` | Creation time. |
@@ -517,7 +517,7 @@ Deleting an owning user or project cascades to scoped keys; deleting a key clear
### `project_repos` ### `project_repos`
Join table for project-to-repository visibility/attachment, including foreign repository attachment. Foreign attachments grant viewer-only access and are effective only while the repository's `attachable` flag is enabled. Join table for project-to-repository visibility/attachment, including foreign repository attachment. Foreign attachments are references to origin-project repositories; they do not grant repository read/write permission by themselves.
| Column | Purpose | | Column | Purpose |
| --- | --- | | --- | --- |
+10 -1
View File
@@ -12292,7 +12292,16 @@ components:
description: Optional GPG signing key ID for Git/RPM repositories. description: Optional GPG signing key ID for Git/RPM repositories.
attachable: attachable:
type: boolean type: boolean
description: Allows other projects to attach this repository as read-only foreign content. description: Allows other projects to attach this repository as a foreign reference. Access still follows origin project permissions.
owner_project_name:
type: string
description: Display name of the origin project when returned with repository list/detail responses.
owner_project:
type: string
description: Public ID of the origin project when returned with repository list/detail responses.
owner_slug:
type: string
description: Slug of the origin project when returned with repository list/detail responses.
additionalProperties: true additionalProperties: true
Issue: Issue:
type: object type: object
+6
View File
@@ -338,6 +338,7 @@ export interface AvailableRepo {
project_id: string project_id: string
name: string name: string
type?: 'git' | 'rpm' | 'docker' type?: 'git' | 'rpm' | 'docker'
owner_project_name: string
owner_project: string owner_project: string
owner_slug: string owner_slug: string
} }
@@ -405,12 +406,17 @@ export interface Upload {
export interface ProjectMember { export interface ProjectMember {
project_id: string project_id: string
user_id: string user_id: string
username?: string
display_name?: string
email?: string
avatar_url?: string
role: string role: string
} }
export interface ProjectGroupRole { export interface ProjectGroupRole {
project_id: string project_id: string
group_id: string group_id: string
group_name?: string
role: string role: string
created_at: number created_at: number
} }
@@ -0,0 +1,48 @@
import { Chip, Tooltip } from '@mui/material'
import { Link, useLocation } from 'react-router-dom'
import { Repo } from '../api'
import { repoIsForeignContext } from '../repoAccess'
type RepoForeignChipProps = {
projectId: string | undefined
repo: Repo | null | undefined
}
export default function RepoForeignChip(props: RepoForeignChipProps) {
const location = useLocation()
const { projectId, repo } = props
const foreignContext: boolean = repoIsForeignContext(projectId, repo)
let originLabel: string
let currentRoot: string
let originRoot: string
let pathname: string
let targetPath: string
let target: string
let title: string
if (!foreignContext || !repo) return null
originLabel = repo.owner_project_name || repo.owner_slug || 'origin'
currentRoot = projectId ? `/projects/${projectId}/repos/${repo.id}` : ''
originRoot = `/projects/${repo.project_id}/repos/${repo.id}`
pathname = location.pathname
targetPath = currentRoot && pathname.startsWith(currentRoot)
? originRoot + pathname.slice(currentRoot.length)
: originRoot
target = `${targetPath}${location.search}${location.hash}`
title = `Origin project: ${originLabel}. Open this repository page in the origin project context.`
return (
<Tooltip title={title}>
<Chip
component={Link}
to={target}
clickable
label={`foreign: ${originLabel}`}
size="small"
color="warning"
variant="outlined"
/>
</Tooltip>
)
}
+14 -9
View File
@@ -412,10 +412,15 @@ export default function ProjectReposPage() {
void navigator.clipboard.writeText(value) void navigator.clipboard.writeText(value)
} }
function repoAccessible(repo: Repo): boolean { function repoAttachmentEnabled(repo: Repo): boolean {
return !repo.is_foreign || Boolean(repo.attachable) return !repo.is_foreign || Boolean(repo.attachable)
} }
function repoOpenProjectId(repo: Repo): string {
if (repo.is_foreign) return repo.owner_project || repo.project_id
return projectId || repo.project_id
}
const columns: RSTColumn<Repo>[] = [ const columns: RSTColumn<Repo>[] = [
{ {
key: 'select', key: 'select',
@@ -440,13 +445,13 @@ export default function ProjectReposPage() {
minWidth: 100, minWidth: 100,
getValue: (repo: Repo) => repo.name, getValue: (repo: Repo) => repo.name,
renderCell: (repo: Repo) => { renderCell: (repo: Repo) => {
const accessible: boolean = repoAccessible(repo) const attachmentEnabled: boolean = repoAttachmentEnabled(repo)
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
{accessible ? ( {attachmentEnabled ? (
<Box <Box
component={Link} component={Link}
to={`/projects/${projectId}/repos/${repo.id}`} to={`/projects/${repoOpenProjectId(repo)}/repos/${repo.id}`}
sx={{ sx={{
display: 'block', display: 'block',
textDecoration: 'none', textDecoration: 'none',
@@ -500,14 +505,14 @@ export default function ProjectReposPage() {
}, },
renderCell: (repo: Repo) => { renderCell: (repo: Repo) => {
const access: RepoAccessInfo = repoAccess(repo) const access: RepoAccessInfo = repoAccess(repo)
const accessible: boolean = repoAccessible(repo) const showCopy: boolean = !repo.is_foreign && Boolean(access.copyValue)
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
<Chip label={access.label} size="small" variant="outlined" sx={{ flexShrink: 0 }} /> <Chip label={access.label} size="small" variant="outlined" sx={{ flexShrink: 0 }} />
<Typography variant="body2" color="text.secondary" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}> <Typography variant="body2" color="text.secondary" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
{access.displayValue || '-'} {access.displayValue || '-'}
</Typography> </Typography>
{access.copyValue && accessible ? ( {showCopy ? (
<Tooltip title="Copy access URL"> <Tooltip title="Copy access URL">
<IconButton <IconButton
size="small" size="small"
@@ -531,11 +536,11 @@ export default function ProjectReposPage() {
label: 'Status', label: 'Status',
defaultWidth: 130, defaultWidth: 130,
minWidth: 80, minWidth: 80,
getValue: (repo: Repo) => `${repo.is_foreign ? 'foreign' : 'local'} ${repo.owner_slug || ''} ${repoAccessible(repo) ? 'accessible' : 'not accessible'}`, getValue: (repo: Repo) => `${repo.is_foreign ? 'foreign' : 'local'} ${repo.owner_slug || ''} ${repoAttachmentEnabled(repo) ? 'attached' : 'not accessible'}`,
renderCell: (repo: Repo) => ( renderCell: (repo: Repo) => (
repo.is_foreign ? ( repo.is_foreign ? (
<Tooltip title={repoAccessible(repo) ? (repo.owner_slug ? `Owned by ${repo.owner_slug}` : 'Foreign repository') : 'The owner turned off attachment for this repository.'}> <Tooltip title={repoAttachmentEnabled(repo) ? (repo.owner_slug ? `Attached from ${repo.owner_slug}. Opens in the origin project context.` : 'Attached foreign repository') : 'The owner turned off attachment for this repository.'}>
<Chip label={repoAccessible(repo) ? 'foreign' : 'not accessible'} size="small" color={repoAccessible(repo) ? 'warning' : 'default'} variant="outlined" /> <Chip label={repoAttachmentEnabled(repo) ? 'foreign' : 'not accessible'} size="small" color={repoAttachmentEnabled(repo) ? 'warning' : 'default'} variant="outlined" />
</Tooltip> </Tooltip>
) : ( ) : (
<Chip label="local" size="small" variant="outlined" /> <Chip label="local" size="small" variant="outlined" />
+22 -13
View File
@@ -283,17 +283,19 @@ export default function ProjectSettingsPage() {
const memberName = (userID: string) => { const memberName = (userID: string) => {
const user = memberUsers.find((item) => item.id === userID) const user = memberUsers.find((item) => item.id === userID)
if (!user) return userID if (!user) return 'Unknown user'
if (user.display_name) return `${user.display_name} (${user.username})` if (user.display_name) return `${user.display_name} (${user.username})`
return user.username return user.username
} }
const groupName = (groupID: string) => { const groupName = (groupID: string) => {
const group = allGroups.find((item) => item.id === groupID) const group = allGroups.find((item) => item.id === groupID)
if (!group) return groupID if (!group) return 'Unknown group'
return group.name return group.name
} }
const projectActionTitle: string = canManageMembers ? '' : 'Project admin permission is required'
return ( return (
<ProjectPageFrame projectId={projectId ?? ''} project={project} currentLabel="Settings"> <ProjectPageFrame projectId={projectId ?? ''} project={project} currentLabel="Settings">
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
@@ -301,12 +303,16 @@ export default function ProjectSettingsPage() {
project={project} project={project}
actions={ actions={
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}> <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<HeaderActionButton variant="outlined" startIcon={<EditIcon />} onClick={openEdit} disabled={!project}> <span title={projectActionTitle}>
Edit Project <HeaderActionButton variant="outlined" startIcon={<EditIcon />} onClick={openEdit} disabled={!project || !canManageMembers}>
</HeaderActionButton> Edit Project
<HeaderActionButton variant="outlined" color="error" startIcon={<DeleteOutlinedIcon />} onClick={openDelete} disabled={!project}> </HeaderActionButton>
Delete Project </span>
</HeaderActionButton> <span title={projectActionTitle}>
<HeaderActionButton variant="outlined" color="error" startIcon={<DeleteOutlinedIcon />} onClick={openDelete} disabled={!project || !canManageMembers}>
Delete Project
</HeaderActionButton>
</span>
</Box> </Box>
} }
/> />
@@ -321,12 +327,15 @@ export default function ProjectSettingsPage() {
updatingId={updatingMemberUserID} updatingId={updatingMemberUserID}
removingId={removingMemberUserID} removingId={removingMemberUserID}
items={members.map((member) => { items={members.map((member) => {
const user = memberUsers.find((item) => item.id === member.user_id) const user: User | undefined = memberUsers.find((item) => item.id === member.user_id)
const displayName: string = member.display_name || user?.display_name || ''
const username: string = member.username || user?.username || ''
const email: string = member.email || user?.email || ''
return { return {
id: member.user_id, id: member.user_id,
primary: user?.display_name || user?.username || memberName(member.user_id), primary: displayName || username || memberName(member.user_id),
secondary: user ? (user.display_name ? user.username : user.email) : undefined, secondary: displayName ? username : email,
avatarUrl: user?.avatar_url, avatarUrl: member.avatar_url || user?.avatar_url,
role: member.role, role: member.role,
} }
})} })}
@@ -349,7 +358,7 @@ export default function ProjectSettingsPage() {
removingId={removingGroupRoleID} removingId={removingGroupRoleID}
items={groupRoles.map((groupRole) => ({ items={groupRoles.map((groupRole) => ({
id: groupRole.group_id, id: groupRole.group_id,
primary: groupName(groupRole.group_id), primary: groupRole.group_name || groupName(groupRole.group_id),
role: groupRole.role, role: groupRole.role,
isGroup: true, isGroup: true,
}))} }))}
+48 -11
View File
@@ -13,19 +13,56 @@ export default function RepoDetailPage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
useEffect(() => { useEffect(() => {
if (!repoId) return let cancelled: boolean
setLoading(true) const load: () => Promise<void> = async () => {
setLoadError(null) let data: Repo
api let message: string
.getRepo(repoId) let items: Repo[]
.then((data) => setRepo(data)) let found: Repo | undefined
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load repository' if (!repoId) return
setLoading(true)
setLoadError(null)
try {
data = await api.getRepo(repoId)
if (cancelled) return
setRepo(data)
} catch (err) {
message = err instanceof Error ? err.message : 'Failed to load repository'
if (!projectId) {
if (cancelled) return
setLoadError(message)
setRepo(null)
return
}
try {
items = await api.listRepos(projectId)
if (cancelled) return
found = items.find((item: Repo) => item.id === repoId)
if (found) {
setRepo(found)
return
}
} catch {
// Keep the original repo-load failure; the fallback only exists
// to render attached-path errors with normal repo page chrome.
}
if (cancelled) return
setLoadError(message) setLoadError(message)
setRepo(null) setRepo(null)
}) } finally {
.finally(() => setLoading(false)) if (cancelled) return
}, [repoId]) setLoading(false)
}
}
if (!repoId) return
cancelled = false
void load()
return () => {
cancelled = true
}
}, [repoId, projectId])
if (!repoId || !projectId) { if (!repoId || !projectId) {
return ( return (
+2 -2
View File
@@ -1,6 +1,6 @@
import { Repo } from './api' import { Repo } from './api'
export const foreignRepoUnavailableMessage: string = 'This attached repository is no longer accessible because the owner turned off attachment.' export const foreignRepoUnavailableMessage: string = 'Open this attached repository from the origin project context.'
export function repoIsForeignContext(projectId: string | undefined, repo: Repo | null | undefined): boolean { export function repoIsForeignContext(projectId: string | undefined, repo: Repo | null | undefined): boolean {
if (!projectId || !repo) return false if (!projectId || !repo) return false
@@ -9,5 +9,5 @@ export function repoIsForeignContext(projectId: string | undefined, repo: Repo |
export function repoContextAccessError(projectId: string | undefined, repo: Repo | null | undefined): string | null { export function repoContextAccessError(projectId: string | undefined, repo: Repo | null | undefined): string | null {
if (!repoIsForeignContext(projectId, repo)) return null if (!repoIsForeignContext(projectId, repo)) return null
return repo?.attachable ? null : foreignRepoUnavailableMessage return foreignRepoUnavailableMessage
} }