+ {{svg "octicon-alert" 16 "tw-mt-1 tw-flex-shrink-0"}}
+
+ {{- if .Repository.IsArchived -}}
+ {{ctx.Locale.Tr "repo.settings.article_archived_notice" (DateUtils.AbsoluteLong (Iif .Repository.ArchivedUnix.IsZero .Repository.UpdatedUnix .Repository.ArchivedUnix))}}
+ {{- end -}}
+
+
+
+ {{/* Shown to the pending transfer's recipient so the article can be accepted or
+ rejected without visiting the settings of an article they do not own yet. */}}
+ {{if and .RepoTransfer .CanUserAcceptOrRejectTransfer}}
+
+
+ {{end}}
+
{{/* Login banner for non-authenticated users */}}
{{if and .IsArticleModeEdit (not .IsSigned)}}
+ {{svg "octicon-info" 16 "tw-flex-shrink-0"}}
+ {{ctx.Locale.Tr "repo.settings.article_transfer_recipient_notice"
+ (HTMLFormat `%s` .RepoTransfer.Doer.HomeLink .RepoTransfer.Doer.DisplayName)
+ (HTMLFormat `%s` .RepoLink (.Repository.GetSubject ctx))}}
+
+
+
+
+
+
@@ -155,7 +191,6 @@
{{end}}
{{template "repo/header" .}}
-
{{ $subjectPath := printf "%s/subject/%s" AppSubUrl (PathEscapeSegments (.Repository.GetSubject ctx)) }}
{{svg "octicon-book" 16}} Read
-
- {{svg "octicon-pencil" 16}} Edit
-
+ {{if not .Repository.IsArchived}}
+
+ {{svg "octicon-pencil" 16}} Edit
+
+ {{end}}
{{svg "octicon-history" 16}} History
+ {{if .IsRepoOwner}}
+
+ {{svg "octicon-gear" 16}} {{ctx.Locale.Tr "repo.settings"}}
+
+ {{end}}
diff --git a/custom/templates/shared/repo/edit.tmpl b/custom/templates/shared/repo/edit.tmpl
index 034a395ba7..c3d7cf1a4c 100644
--- a/custom/templates/shared/repo/edit.tmpl
+++ b/custom/templates/shared/repo/edit.tmpl
@@ -1,5 +1,5 @@
{{define "shared/repo/edit"}}
-{{if .IsArticleModeEdit}}
+{{if and .IsArticleModeEdit (not .Repository.IsArchived)}}
{{if .IsArticleModeRead}}
{{end}}
- {{if .IsArticleModeEdit}}
+ {{if and .IsArticleModeEdit (not .Repository.IsArchived)}}
@@ -65,7 +74,7 @@
{{if .IsSigned}}
@@ -152,6 +161,7 @@
{{template "shared/repo/read" .}}
{{template "shared/repo/edit" .}}
{{template "shared/repo/history" .}}
+ {{template "shared/repo/settings" .}}
{{end}}
diff --git a/custom/templates/shared/repo/settings.tmpl b/custom/templates/shared/repo/settings.tmpl
new file mode 100644
index 0000000000..60ddc40cdb
--- /dev/null
+++ b/custom/templates/shared/repo/settings.tmpl
@@ -0,0 +1,245 @@
+{{define "shared/repo/settings"}}
+{{if and .IsArticleModeSettings .IsRepoOwner}}
+{{$subjectName := .Repository.GetSubject ctx}}
+/", identifies the recipient by full name and always reports
+// back on the article settings page.
+func handleArticleSettingsPostTransfer(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.RepoSettingForm)
+ repo := ctx.Repo.Repository
+ redirectURL := articleSettingsURL(ctx)
+
+ if form.ArticleName != ctx.Repo.Owner.Name+"/"+repo.GetSubject(ctx) {
+ ctx.Flash.Error(ctx.Tr("form.enterred_invalid_article_name"))
+ ctx.Redirect(redirectURL)
+ return
+ }
+
+ newOwner := resolveArticleTransferRecipient(ctx, ctx.FormString("new_owner_name"))
+ if newOwner == nil {
+ if !ctx.Written() {
+ ctx.Redirect(redirectURL)
+ }
+ return
+ }
+
+ // Close the GitRepo if open
+ if ctx.Repo.GitRepo != nil {
+ ctx.Repo.GitRepo.Close()
+ ctx.Repo.GitRepo = nil
+ }
+
+ if err := repo_service.StartRepositoryTransfer(ctx, ctx.Doer, newOwner, repo, nil); err != nil {
+ switch {
+ case repo_model.IsErrRepoAlreadyExist(err):
+ ctx.Flash.Error(ctx.Tr("repo.settings.article_transfer_owner_has_article"))
+ case repo_model.IsErrRepoTransferInProgress(err):
+ ctx.Flash.Error(ctx.Tr("repo.settings.article_transfer_in_progress"))
+ case repo_service.IsRepositoryLimitReached(err):
+ limit := err.(repo_service.LimitReachedError).Limit
+ ctx.Flash.Error(ctx.TrN(limit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", limit))
+ case errors.Is(err, user_model.ErrBlockedUser):
+ ctx.Flash.Error(ctx.Tr("repo.settings.transfer.blocked_user"))
+ default:
+ ctx.ServerError("TransferOwnership", err)
+ return
+ }
+ ctx.Redirect(redirectURL)
+ return
+ }
+
+ recipientLink := htmlutil.HTMLFormat(`%s`, newOwner.HomeLink(), newOwner.DisplayName())
+ if repo.Status == repo_model.RepositoryPendingTransfer {
+ log.Trace("Article transfer process was started: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner.Name)
+ ctx.Flash.Success(ctx.Tr("repo.settings.article_transfer_started", recipientLink))
+ } else {
+ log.Trace("Article transferred: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner.Name)
+ ctx.Flash.Success(ctx.Tr("repo.settings.article_transfer_succeed", recipientLink))
+ }
+ ctx.Redirect(redirectURL)
+}
+
func handleSettingsPostTransfer(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.RepoSettingForm)
repo := ctx.Repo.Repository
@@ -815,6 +939,12 @@ func handleSettingsPostTransfer(ctx *context.Context) {
ctx.HTTPError(http.StatusNotFound)
return
}
+
+ if ctx.FormBool("redirect_to_article") {
+ handleArticleSettingsPostTransfer(ctx)
+ return
+ }
+
if repo.Name != form.RepoName {
ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil)
return
@@ -879,11 +1009,19 @@ func handleSettingsPostCancelTransfer(ctx *context.Context) {
return
}
+ // The article settings UI posts to this same dispatcher, but it must return to
+ // the article view instead of the repository settings page.
+ fromArticle := ctx.FormBool("redirect_to_article")
+ redirectURL := repo.Link() + "/settings"
+ if fromArticle {
+ redirectURL = articleSettingsURL(ctx)
+ }
+
repoTransfer, err := repo_model.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository)
if err != nil {
if repo_model.IsErrNoPendingTransfer(err) {
- ctx.Flash.Error("repo.settings.transfer_abort_invalid")
- ctx.Redirect(repo.Link() + "/settings")
+ ctx.Flash.Error(ctx.Tr("repo.settings.transfer_abort_invalid"))
+ ctx.Redirect(redirectURL)
} else {
ctx.ServerError("GetPendingRepositoryTransfer", err)
}
@@ -896,8 +1034,16 @@ func handleSettingsPostCancelTransfer(ctx *context.Context) {
}
log.Trace("Repository transfer process was cancelled: %s/%s ", ctx.Repo.Owner.Name, repo.Name)
- ctx.Flash.Success(ctx.Tr("repo.settings.transfer_abort_success", repoTransfer.Recipient.Name))
- ctx.Redirect(repo.Link() + "/settings")
+ if fromArticle {
+ if err := repoTransfer.LoadRecipient(ctx); err != nil {
+ ctx.ServerError("LoadRecipient", err)
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("repo.settings.article_transfer_abort_success", repoTransfer.Recipient.DisplayName()))
+ } else {
+ ctx.Flash.Success(ctx.Tr("repo.settings.transfer_abort_success", repoTransfer.Recipient.Name))
+ }
+ ctx.Redirect(redirectURL)
}
func handleSettingsPostDelete(ctx *context.Context) {
@@ -907,11 +1053,23 @@ func handleSettingsPostDelete(ctx *context.Context) {
ctx.HTTPError(http.StatusNotFound)
return
}
- if repo.Name != form.RepoName {
+
+ // The article settings UI confirms the deletion with "/" and
+ // must return to the owner profile instead of the repository settings page.
+ fromArticle := ctx.FormBool("redirect_to_article")
+ if fromArticle {
+ if form.ArticleName != ctx.Repo.Owner.Name+"/"+repo.GetSubject(ctx) {
+ ctx.Flash.Error(ctx.Tr("form.enterred_invalid_article_name"))
+ ctx.Redirect(ctx.Repo.RepoLink + "?view=article&mode=settings")
+ return
+ }
+ } else if repo.Name != form.RepoName {
ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil)
return
}
+ subjectID := repo.SubjectID
+
// Close the gitrepository before doing this.
if ctx.Repo.GitRepo != nil {
ctx.Repo.GitRepo.Close()
@@ -923,8 +1081,24 @@ func handleSettingsPostDelete(ctx *context.Context) {
}
log.Trace("Repository deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name)
- ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success"))
- ctx.Redirect(ctx.Repo.Owner.DashboardLink())
+ // The subject only exists to group articles, so drop it once its last article is gone.
+ if subjectID > 0 {
+ count, err := repo_model.CountRepositoriesBySubject(ctx, subjectID)
+ if err != nil {
+ log.Error("CountRepositoriesBySubject [%d]: %v", subjectID, err)
+ } else if count == 0 {
+ if err := repo_model.DeleteSubject(ctx, subjectID); err != nil {
+ log.Error("DeleteSubject [%d]: %v", subjectID, err)
+ }
+ }
+ }
+
+ if fromArticle {
+ ctx.Flash.Success(ctx.Tr("repo.settings.article_delete_success"))
+ } else {
+ ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success"))
+ }
+ ctx.Redirect(ctx.Repo.Owner.HomeLink())
}
func handleSettingsPostDeleteWiki(ctx *context.Context) {
@@ -956,16 +1130,24 @@ func handleSettingsPostArchive(ctx *context.Context) {
return
}
+ // The article settings UI posts to this same dispatcher, but it must return to
+ // the article view instead of the repository settings page.
+ fromArticle := ctx.FormBool("redirect_to_article")
+ redirectURL := ctx.Repo.RepoLink + "/settings"
+ if fromArticle {
+ redirectURL = ctx.Repo.RepoLink + "?view=article&mode=settings"
+ }
+
if repo.IsMirror {
ctx.Flash.Error(ctx.Tr("repo.settings.archive.error_ismirror"))
- ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+ ctx.Redirect(redirectURL)
return
}
if err := repo_model.SetArchiveRepoState(ctx, repo, true); err != nil {
log.Error("Tried to archive a repo: %s", err)
ctx.Flash.Error(ctx.Tr("repo.settings.archive.error"))
- ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+ ctx.Redirect(redirectURL)
return
}
@@ -976,10 +1158,14 @@ func handleSettingsPostArchive(ctx *context.Context) {
// update issue indexer
issue_indexer.UpdateRepoIndexer(ctx, repo.ID)
- ctx.Flash.Success(ctx.Tr("repo.settings.archive.success"))
+ if fromArticle {
+ ctx.Flash.Success(ctx.Tr("repo.settings.article_archive_success"))
+ } else {
+ ctx.Flash.Success(ctx.Tr("repo.settings.archive.success"))
+ }
log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name)
- ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+ ctx.Redirect(redirectURL)
}
func handleSettingsPostUnarchive(ctx *context.Context) {
diff --git a/routers/web/web.go b/routers/web/web.go
index 60b42655dc..ae2b9bb762 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1238,9 +1238,12 @@ func registerWebRoutes(m *web.Router) {
// Article-based file operation routes - mirror the repository-based routes but use subject name
m.Group("/article/{username}/{subjectname}", func() {
registerRepoFileEditorRoutes(m, reqRepoCodeWriter)
- }, reqSignIn, context.RepoAssignmentByOwnerAndSubject, reqUnitCodeReader)
+ }, reqSignIn, context.RepoAssignmentByOwnerAndSubject, reqUnitCodeReader, context.RepoMustNotBeArchived())
// end "/article/{username}/{subjectname}": article-based file operations
+ // Article settings helpers, the article settings UI lives on the article view itself
+ m.Get("/article/{username}/{subjectname}/settings/transfer_candidates", reqSignIn, context.RepoAssignmentByOwnerAndSubject, reqRepoAdmin, repo_setting.ArticleTransferCandidates)
+
// Article-based pull request routes - mirror the repository-based routes but use subject name
m.Group("/article/{username}/{subjectname}", func() {
m.Get("/{type:pulls}", repo.Issues)
diff --git a/services/context/repo.go b/services/context/repo.go
index 6f9667d8fc..a6b51d1873 100644
--- a/services/context/repo.go
+++ b/services/context/repo.go
@@ -93,7 +93,7 @@ func (r *Repository) GetObjectFormat() git.ObjectFormat {
func RepoMustNotBeArchived() func(ctx *Context) {
return func(ctx *Context) {
if ctx.Repo.Repository.IsArchived {
- ctx.NotFound(errors.New(ctx.Locale.TrString("repo.archive.title")))
+ ctx.HTTPError(http.StatusForbidden, ctx.Locale.TrString("repo.archive.title"))
}
}
}
@@ -1360,4 +1360,23 @@ func RepoAssignmentByOwnerAndSubject(ctx *Context) {
return
}
}
+
+ // The article view shows the recipient a banner to accept or reject a pending transfer
+ if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer {
+ repoTransfer, err := repo_model.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.ServerError("GetPendingRepositoryTransfer", err)
+ return
+ }
+
+ if err := repoTransfer.LoadAttributes(ctx); err != nil {
+ ctx.ServerError("LoadAttributes", err)
+ return
+ }
+
+ ctx.Data["RepoTransfer"] = repoTransfer
+ if ctx.Doer != nil {
+ ctx.Data["CanUserAcceptOrRejectTransfer"] = repoTransfer.CanUserAcceptOrRejectTransfer(ctx, ctx.Doer)
+ }
+ }
}
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index a943c388b3..16d7de4e65 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -96,7 +96,9 @@ func (f *MigrateRepoForm) Validate(req *http.Request, errs binding.Errors) bindi
// RepoSettingForm form for changing repository settings
type RepoSettingForm struct {
- RepoName string `binding:"Required;AlphaDashDot;MaxSize(100)"`
+ RepoName string `binding:"Required;AlphaDashDot;MaxSize(100)"`
+ // ArticleName is the "/" confirmation typed in the article settings modals
+ ArticleName string `binding:"MaxSize(255)"`
Subject string `binding:"MaxSize(255)"`
Description string `binding:"MaxSize(2048)"`
Website string `binding:"ValidUrl;MaxSize(1024)"`
diff --git a/tests/e2e/fork-article-modal.test.e2e.ts b/tests/e2e/fork-article-modal.test.e2e.ts
index f4d30b37e8..1c839c3e95 100644
--- a/tests/e2e/fork-article-modal.test.e2e.ts
+++ b/tests/e2e/fork-article-modal.test.e2e.ts
@@ -260,7 +260,14 @@ test.describe('Fork-on-Edit Permission Tests', () => {
// The editor creates two .toastui-editor elements (md-mode and ww-mode), so use .first()
await expect(page.locator('.toastui-editor').first()).toBeAttached({timeout: 20000});
- await submitButton.click();
+ // Scroll button into view and ensure it's clickable
+ await submitButton.scrollIntoViewIfNeeded();
+ // eslint-disable-next-line playwright/no-wait-for-timeout
+ await page.waitForTimeout(500);
+
+ // Use force click for mobile browsers to avoid click interception issues
+ // eslint-disable-next-line playwright/no-force-option
+ await submitButton.click({force: true});
// No confirmation modal should appear for repo owner
const modal = page.locator('.ui.g-modal-confirm.modal.visible');
diff --git a/tests/integration/article_settings_archive_test.go b/tests/integration/article_settings_archive_test.go
new file mode 100644
index 0000000000..8de91f5791
--- /dev/null
+++ b/tests/integration/article_settings_archive_test.go
@@ -0,0 +1,207 @@
+// Copyright 2025 okTurtles Foundation. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/perm"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/test"
+ repo_service "code.gitea.io/gitea/services/repository"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// loadArticleRepo returns the owner, the repository and its subject name, skipping
+// the test when the fixture has no subject attached.
+func loadArticleRepo(t *testing.T, repoID int64) (*user_model.User, *repo_model.Repository, string) {
+ t.Helper()
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ require.NoError(t, repo.LoadSubject(t.Context()))
+ if repo.SubjectRelation == nil {
+ t.Skipf("repo %d has no subject, skipping", repoID)
+ }
+ return owner, repo, repo.SubjectRelation.Name
+}
+
+// archiveForm builds the payload the article archive modal submits.
+func archiveForm(csrf, owner, subject string) map[string]string {
+ return map[string]string{
+ "_csrf": csrf,
+ "action": "archive",
+ "redirect_to_article": "true",
+ "article_name": owner + "/" + subject,
+ }
+}
+
+func TestArticleSettingsArchiveSuccess(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ owner, repo, subjectName := loadArticleRepo(t, 1)
+ assert.False(t, repo.IsArchived)
+
+ session := loginUser(t, owner.Name)
+ settingsURL := fmt.Sprintf("/%s/%s/settings", owner.Name, repo.Name)
+
+ // the archived notice is rendered but hidden while the article is not archived
+ req := NewRequest(t, "GET", fmt.Sprintf("/article/%s/%s?view=article", owner.Name, subjectName))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ notice := NewHTMLParser(t, resp.Body).Find("#article-archived-notice")
+ require.Equal(t, 1, notice.Length())
+ assert.True(t, notice.HasClass("tw-hidden"))
+ assert.Empty(t, strings.TrimSpace(notice.Text()))
+
+ req = NewRequestWithValues(t, "POST", settingsURL,
+ archiveForm(GetUserCSRFToken(t, session), owner.Name, subjectName))
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+
+ articleSettingsURL := fmt.Sprintf("/article/%s/%s?view=article&mode=settings", owner.Name, subjectName)
+ assert.Equal(t, articleSettingsURL, test.RedirectURL(resp))
+
+ // the flash message is carried over in a cookie, so it renders on the next page
+ req = NewRequest(t, "GET", articleSettingsURL)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ flash := htmlDoc.Find(".flash-message")
+ require.Equal(t, 1, flash.Length())
+ assert.Contains(t, flash.Text(), "This article has been archived.")
+
+ archived := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
+ assert.True(t, archived.IsArchived)
+}
+
+func TestArticleArchivedReadOnly(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ owner, repo, subjectName := loadArticleRepo(t, 1)
+ require.NoError(t, repo_model.SetArchiveRepoState(t.Context(), repo, true))
+
+ session := loginUser(t, owner.Name)
+ articleURL := fmt.Sprintf("/article/%s/%s?view=article", owner.Name, subjectName)
+
+ // the notice sits above the article section, so it renders on every mode
+ for _, mode := range []string{"read", "history", "settings"} {
+ t.Run("Notice_"+mode, func(t *testing.T) {
+ req := NewRequest(t, "GET", articleURL+"&mode="+mode)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ notice := htmlDoc.Find("#article-archived-notice")
+ require.Equal(t, 1, notice.Length())
+ // the container is a flex box, so it is hidden by class, not by the hidden attribute
+ assert.False(t, notice.HasClass("tw-hidden"))
+ assert.Contains(t, notice.Text(), "This article has been archived by the owner on")
+ assert.Contains(t, notice.Text(), "It is read-only.")
+ // the notice must carry the archival date, not the last update date
+ // (the date element renders its ISO fallback server-side)
+ assert.Contains(t, notice.Text(), repo.ArchivedUnix.AsTime().Format(time.DateOnly))
+ // it sits at the very top of the page, above the repository header
+ assert.Equal(t, 1, notice.NextAllFiltered(".secondary-nav").Length())
+ assert.Equal(t, 0, notice.PrevAllFiltered(".secondary-nav").Length())
+ })
+ }
+
+ // the notice belongs to the article view only
+ for _, view := range []string{"bubble", "table"} {
+ t.Run("NoticeHidden_"+view, func(t *testing.T) {
+ req := NewRequest(t, "GET", fmt.Sprintf("/subject/%s?view=%s", subjectName, view))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ notice := htmlDoc.Find("#article-archived-notice")
+ require.Equal(t, 1, notice.Length())
+ assert.True(t, notice.HasClass("tw-hidden"))
+ })
+ }
+
+ t.Run("EditTabHidden", func(t *testing.T) {
+ // hidden for the owner too, not only for readers
+ req := NewRequest(t, "GET", articleURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ AssertHTMLElement(t, htmlDoc, `[data-article-tab="edit"]`, false)
+ AssertHTMLElement(t, htmlDoc, `[data-article-tab="read"]`, true)
+ })
+
+ t.Run("EditorRoutesForbidden", func(t *testing.T) {
+ csrf := GetUserCSRFToken(t, session)
+ editorPath := fmt.Sprintf("/article/%s/%s", owner.Name, subjectName)
+
+ for _, tc := range []struct {
+ name string
+ method string
+ path string
+ }{
+ {"EditGet", "GET", editorPath + "/_edit/master/README.md"},
+ {"EditPost", "POST", editorPath + "/_edit/master/README.md"},
+ {"NewGet", "GET", editorPath + "/_new/master/new.md"},
+ {"NewPost", "POST", editorPath + "/_new/master/new.md"},
+ {"DeleteGet", "GET", editorPath + "/_delete/master/README.md"},
+ {"DeletePost", "POST", editorPath + "/_delete/master/README.md"},
+ {"UploadGet", "GET", editorPath + "/_upload/master"},
+ {"UploadPost", "POST", editorPath + "/_upload/master"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var req *RequestWrapper
+ if tc.method == "GET" {
+ req = NewRequest(t, "GET", tc.path)
+ } else {
+ req = NewRequestWithValues(t, "POST", tc.path, map[string]string{
+ "_csrf": csrf,
+ "tree_path": "README.md",
+ "content": "archived write attempt",
+ "commit_choice": "direct",
+ })
+ }
+ session.MakeRequest(t, req, http.StatusForbidden)
+ })
+ }
+ })
+}
+
+func TestArticleSettingsArchiveUnauthorized(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ owner, repo, subjectName := loadArticleRepo(t, 1)
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
+ settingsURL := fmt.Sprintf("/%s/%s/settings", owner.Name, repo.Name)
+
+ t.Run("NonCollaborator", func(t *testing.T) {
+ // blocked before the handler runs, by the "settings" group admin requirement
+ session := loginUser(t, user4.Name)
+ req := NewRequestWithValues(t, "POST", settingsURL,
+ archiveForm(GetUserCSRFToken(t, session), owner.Name, subjectName))
+ session.MakeRequest(t, req, http.StatusNotFound)
+
+ unarchived := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
+ assert.False(t, unarchived.IsArchived)
+ })
+
+ t.Run("AdminCollaboratorIsNotOwner", func(t *testing.T) {
+ // an admin collaborator passes the group requirement but is rejected by the
+ // owner-only guard in the archive handler
+ require.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), repo, user4, perm.AccessModeAdmin))
+
+ session := loginUser(t, user4.Name)
+ req := NewRequestWithValues(t, "POST", settingsURL,
+ archiveForm(GetUserCSRFToken(t, session), owner.Name, subjectName))
+ session.MakeRequest(t, req, http.StatusForbidden)
+
+ unarchived := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
+ assert.False(t, unarchived.IsArchived)
+ })
+}
diff --git a/tests/integration/article_settings_transfer_test.go b/tests/integration/article_settings_transfer_test.go
new file mode 100644
index 0000000000..8727db0318
--- /dev/null
+++ b/tests/integration/article_settings_transfer_test.go
@@ -0,0 +1,229 @@
+// Copyright 2025 okTurtles Foundation. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// transferForm builds the payload the article transfer modal submits.
+func transferForm(csrf, owner, subject, newOwnerFullName string) map[string]string {
+ return map[string]string{
+ "_csrf": csrf,
+ "action": "transfer",
+ "redirect_to_article": "true",
+ "article_name": owner + "/" + subject,
+ "new_owner_name": newOwnerFullName,
+ }
+}
+
+func TestArticleSettingsTransfer(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ owner, repo, subjectName := loadArticleRepo(t, 1)
+ recipient := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+
+ session := loginUser(t, owner.Name)
+ settingsURL := fmt.Sprintf("/%s/%s/settings", owner.Name, repo.Name)
+ articleSettingsURL := fmt.Sprintf("/article/%s/%s?view=article&mode=settings", owner.Name, subjectName)
+
+ post := func(t *testing.T, form map[string]string) {
+ t.Helper()
+ req := NewRequestWithValues(t, "POST", settingsURL, form)
+ resp := session.MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, articleSettingsURL, test.RedirectURL(resp))
+ }
+
+ flashText := func(t *testing.T) string {
+ t.Helper()
+ req := NewRequest(t, "GET", articleSettingsURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ flash := NewHTMLParser(t, resp.Body).Find(".flash-message")
+ require.Equal(t, 1, flash.Length())
+ return flash.Text()
+ }
+
+ t.Run("WrongArticleName", func(t *testing.T) {
+ form := transferForm(GetUserCSRFToken(t, session), owner.Name, subjectName, recipient.FullName)
+ // the confirmation is case-sensitive on purpose
+ form["article_name"] = owner.Name + "/" + subjectName + "x"
+ post(t, form)
+
+ assert.Contains(t, flashText(t), "The article owner and subject you entered are incorrect.")
+ unchanged := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
+ assert.Equal(t, repo_model.RepositoryReady, unchanged.Status)
+ })
+
+ t.Run("UnknownFullName", func(t *testing.T) {
+ post(t, transferForm(GetUserCSRFToken(t, session), owner.Name, subjectName, "Nobody At All"))
+
+ assert.Contains(t, flashText(t), "No user was found with that first and last name.")
+ unchanged := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
+ assert.Equal(t, repo_model.RepositoryReady, unchanged.Status)
+ })
+
+ t.Run("Start", func(t *testing.T) {
+ // the recipient is resolved by first and last name, not by username
+ post(t, transferForm(GetUserCSRFToken(t, session), owner.Name, subjectName, recipient.FullName))
+
+ assert.Contains(t, flashText(t), "awaits confirmation from "+recipient.DisplayName())
+
+ pending := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
+ assert.Equal(t, repo_model.RepositoryPendingTransfer, pending.Status)
+ unittest.AssertExistsAndLoadBean(t, &repo_model.RepoTransfer{RepoID: repo.ID, RecipientID: recipient.ID})
+ })
+
+ t.Run("PendingHidesTransferButton", func(t *testing.T) {
+ req := NewRequest(t, "GET", articleSettingsURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ AssertHTMLElement(t, htmlDoc, `[data-article-settings-modal="#article-transfer-modal"]`, false)
+ AssertHTMLElement(t, htmlDoc, "#article-transfer-cancel", true)
+ assert.Contains(t, htmlDoc.Find("#article-settings-transfer").Text(),
+ "awaiting confirmation from "+recipient.DisplayName())
+ })
+
+ t.Run("RecipientSeesBanner", func(t *testing.T) {
+ articleURL := fmt.Sprintf("/article/%s/%s?view=article", owner.Name, subjectName)
+
+ // the owner is not the recipient, so no banner is offered to them
+ AssertHTMLElement(t, NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", articleURL), http.StatusOK).Body),
+ "#article-transfer-notice", false)
+
+ recipientSession := loginUser(t, recipient.Name)
+ htmlDoc := NewHTMLParser(t, recipientSession.MakeRequest(t, NewRequest(t, "GET", articleURL), http.StatusOK).Body)
+ AssertHTMLElement(t, htmlDoc, "#article-transfer-notice", true)
+ assert.Contains(t, htmlDoc.Find("#article-transfer-notice").Text(),
+ fmt.Sprintf("%s wants to transfer the article %s to you.", owner.DisplayName(), subjectName))
+ })
+
+ t.Run("Cancel", func(t *testing.T) {
+ post(t, map[string]string{
+ "_csrf": GetUserCSRFToken(t, session),
+ "action": "cancel_transfer",
+ "redirect_to_article": "true",
+ })
+
+ assert.Contains(t, flashText(t),
+ fmt.Sprintf("The article transfer to %s was successfully canceled.", recipient.DisplayName()))
+
+ reverted := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
+ assert.Equal(t, repo_model.RepositoryReady, reverted.Status)
+ unittest.AssertNotExistsBean(t, &repo_model.RepoTransfer{RepoID: repo.ID})
+ })
+
+ t.Run("StartByUsername", func(t *testing.T) {
+ // users without a full name are listed by their username, which must resolve too
+ byUsername := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
+ require.Empty(t, strings.TrimSpace(byUsername.FullName))
+ post(t, transferForm(GetUserCSRFToken(t, session), owner.Name, subjectName, byUsername.Name))
+
+ assert.Contains(t, flashText(t), "awaits confirmation from "+byUsername.DisplayName())
+ unittest.AssertExistsAndLoadBean(t, &repo_model.RepoTransfer{RepoID: repo.ID, RecipientID: byUsername.ID})
+ })
+
+ t.Run("SelfTransferByUsername", func(t *testing.T) {
+ post(t, transferForm(GetUserCSRFToken(t, session), owner.Name, subjectName, owner.Name))
+
+ assert.Contains(t, flashText(t), "This article already belongs to that user.")
+ })
+}
+
+func TestArticleSettingsTransferCandidates(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ owner, repo, subjectName := loadArticleRepo(t, 1)
+ session := loginUser(t, owner.Name)
+ candidatesURL := fmt.Sprintf("/article/%s/%s/settings/transfer_candidates", owner.Name, subjectName)
+
+ searchUsers := func(t *testing.T, keyword string) []*api.User {
+ t.Helper()
+ req := NewRequest(t, "GET", candidatesURL+"?q="+url.QueryEscape(keyword))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ var body struct {
+ Data []*api.User `json:"data"`
+ }
+ DecodeJSON(t, resp, &body)
+ return body.Data
+ }
+
+ search := func(t *testing.T, keyword string) []string {
+ t.Helper()
+ users := searchUsers(t, keyword)
+ logins := make([]string, 0, len(users))
+ for _, u := range users {
+ logins = append(logins, u.UserName)
+ }
+ return logins
+ }
+
+ t.Run("MatchesFullName", func(t *testing.T) {
+ recipient := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ users := searchUsers(t, recipient.FullName)
+
+ var found *api.User
+ for _, u := range users {
+ if u.UserName == recipient.Name {
+ found = u
+ }
+ }
+ require.NotNil(t, found)
+ // the dropdown renders the avatar next to the username
+ assert.NotEmpty(t, found.AvatarURL)
+ })
+
+ t.Run("ExcludesCurrentOwner", func(t *testing.T) {
+ assert.NotContains(t, search(t, owner.Name), owner.Name)
+ })
+
+ t.Run("ExcludesOwnersOfSameSubject", func(t *testing.T) {
+ other := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ sameSubject := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: other.ID})
+ sameSubject.SubjectID = repo.SubjectID
+ _, err := db.GetEngine(t.Context()).ID(sameSubject.ID).Cols("subject_id").Update(sameSubject)
+ require.NoError(t, err)
+
+ assert.NotContains(t, search(t, other.Name), other.Name)
+ })
+
+ t.Run("Unauthorized", func(t *testing.T) {
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
+ otherSession := loginUser(t, user4.Name)
+ req := NewRequest(t, "GET", candidatesURL+"?q=user")
+ otherSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+}
+
+func TestArticleSettingsTransferUnauthorized(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ owner, repo, subjectName := loadArticleRepo(t, 1)
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
+ recipient := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+
+ // blocked before the handler runs, by the "settings" group admin requirement
+ session := loginUser(t, user4.Name)
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings", owner.Name, repo.Name),
+ transferForm(GetUserCSRFToken(t, session), owner.Name, subjectName, recipient.FullName))
+ session.MakeRequest(t, req, http.StatusNotFound)
+
+ unchanged := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
+ assert.Equal(t, repo_model.RepositoryReady, unchanged.Status)
+}
diff --git a/web_src/js/features/article-settings.ts b/web_src/js/features/article-settings.ts
new file mode 100644
index 0000000000..eaf557baeb
--- /dev/null
+++ b/web_src/js/features/article-settings.ts
@@ -0,0 +1,116 @@
+import {fomanticQuery} from '../modules/fomantic/base.ts';
+import {addDelegatedEventListener, hideElem, showElem} from '../utils/dom.ts';
+
+// The transfer modal additionally requires an owner picked from the search results,
+// the button state depends on both the confirmation input and that selection.
+let transferOwnerSelected = false;
+
+// Enables the target button only when the typed value matches the expected one
+// exactly, the comparison is case-sensitive on purpose.
+function syncConfirmInput(input: HTMLInputElement): void {
+ const target = document.querySelector(input.getAttribute('data-article-confirm-target'));
+ if (!target) return;
+ let enabled = input.value === input.getAttribute('data-article-confirm-value');
+ if (enabled && target.id === 'article-transfer-submit') enabled = transferOwnerSelected;
+ target.classList.toggle('disabled', !enabled);
+ target.disabled = !enabled;
+}
+
+function syncTransferConfirmInput(): void {
+ const input = document.querySelector('#article-transfer-confirm-name');
+ if (input) syncConfirmInput(input);
+}
+
+// Resets the "new owner" field back to its searchable state, called when the owner is
+// cleared and every time the transfer modal is reopened.
+let resetTransferOwnerSearch = (): void => {};
+
+// Turns the "new owner" field into a search box listing the users the article can be
+// transferred to. A user is shown by their full name, falling back to the username,
+// and that same value is what the backend resolves the recipient by.
+function initArticleTransferOwnerSearch(): void {
+ const elSearch = document.querySelector('#article-transfer-owner-search');
+ if (!elSearch) return;
+
+ const searchURL = elSearch.getAttribute('data-search-url');
+ const elInput = elSearch.querySelector('input[name="new_owner_name"]');
+ const elPrompt = elInput.closest('.ui.input');
+ const elSelection = elSearch.querySelector('#article-transfer-owner-selection');
+ const elAvatar = elSelection.querySelector('#article-transfer-owner-avatar');
+ const elName = elSelection.querySelector('#article-transfer-owner-name');
+
+ resetTransferOwnerSearch = () => {
+ transferOwnerSelected = false;
+ elInput.value = '';
+ elAvatar.src = '';
+ elName.textContent = '';
+ hideElem(elSelection);
+ showElem(elPrompt);
+ syncTransferConfirmInput();
+ };
+
+ fomanticQuery(elSearch).search({
+ minCharacters: 3,
+ maxResults: 3,
+ cache: true,
+ throttle: 300,
+ showNoResults: false,
+ apiSettings: {
+ url: `${searchURL}?q={query}`,
+ onResponse(response: any) {
+ const results = [];
+ for (const user of response.data) {
+ results.push({
+ title: user.full_name || user.login,
+ image: user.avatar_url,
+ });
+ }
+ return {results};
+ },
+ },
+ onSelect(result: any) {
+ transferOwnerSelected = Boolean(result?.title);
+ if (!transferOwnerSelected) return;
+ elAvatar.src = result.image ?? '';
+ elName.textContent = result.title;
+ hideElem(elPrompt);
+ showElem(elSelection);
+ syncTransferConfirmInput();
+ },
+ });
+
+ elSelection.querySelector('#article-transfer-owner-clear').addEventListener('click', () => {
+ resetTransferOwnerSearch();
+ elInput.focus();
+ });
+
+ elInput.addEventListener('input', () => {
+ transferOwnerSelected = false;
+ syncTransferConfirmInput();
+ });
+}
+
+// Opens the Article settings modals (transfer/archive/delete). Submission is
+// handled by the forms inside the modals.
+export function initArticleSettings(): void {
+ if (!document.querySelector('#article-settings-general')) return;
+
+ initArticleTransferOwnerSearch();
+
+ addDelegatedEventListener(document, 'click', '[data-article-settings-modal]', (el: HTMLElement, e: MouseEvent) => {
+ e.preventDefault();
+ const modal = document.querySelector(el.getAttribute('data-article-settings-modal'));
+ if (!modal) return;
+ // a modal can be reopened, so the confirmation and the owner must be entered again
+ resetTransferOwnerSearch();
+ for (const input of modal.querySelectorAll('[data-article-confirm-value]')) {
+ input.value = '';
+ syncConfirmInput(input);
+ }
+ fomanticQuery(modal).modal('show');
+ });
+
+ addDelegatedEventListener(document, 'input', '[data-article-confirm-value]', (el: HTMLInputElement) => {
+ syncConfirmInput(el);
+ });
+}
diff --git a/web_src/js/features/repo-history.ts b/web_src/js/features/repo-history.ts
index 23043c3150..506014515f 100644
--- a/web_src/js/features/repo-history.ts
+++ b/web_src/js/features/repo-history.ts
@@ -193,6 +193,13 @@ export function initRepoHistory() {
let articleGuidance: HTMLElement | null = null;
let articleEmptyEl: HTMLElement | null = null;
let articleContentEl: HTMLElement | null = null;
+ const archivedNoticeEl = document.querySelector('#article-archived-notice');
+ // the notice is hidden outside the article view, so the archived state is read from its text
+ let isArchivedArticle = Boolean(archivedNoticeText(archivedNoticeEl));
+
+ function archivedNoticeText(el: HTMLElement | null): string {
+ return el?.querySelector('[data-role="article-archived-text"]')?.textContent.trim() || '';
+ }
function collectArticleRefs() {
if (!articleSection) return;
@@ -235,6 +242,25 @@ export function initRepoHistory() {
toggleHidden(articleContentEl, false);
}
+ // The archived notice lives above the article section so it stays visible across
+ // all article modes, so it has to be updated separately when a new article is loaded.
+ // An incoming article without archival metadata clears the banner of the previous one.
+ function syncArchivedNotice(doc: Document) {
+ if (!archivedNoticeEl) return;
+ const incoming = doc.querySelector('#article-archived-notice');
+ const incomingText = archivedNoticeText(incoming);
+ isArchivedArticle = Boolean(incomingText);
+ const text = archivedNoticeEl.querySelector('[data-role="article-archived-text"]');
+ if (text) text.textContent = incomingText;
+ updateArchivedNoticeVisibility();
+ }
+
+ function updateArchivedNoticeVisibility() {
+ if (!archivedNoticeEl) return;
+ // the notice is a flex container, so it has to be hidden by class rather than by attribute
+ archivedNoticeEl.classList.toggle('tw-hidden', !isArchivedArticle || activeView.value !== 'article');
+ }
+
function syncNavActive() {
if (!navEl) return;
for (const anchor of navEl.querySelectorAll('a[data-view]')) {
@@ -277,6 +303,7 @@ export function initRepoHistory() {
toggleHidden(bubbleSection, activeView.value !== 'bubble');
toggleHidden(tableSection, activeView.value !== 'table');
toggleHidden(articleSection, activeView.value !== 'article');
+ updateArchivedNoticeVisibility();
}
function updateCheckboxes() {
@@ -465,6 +492,7 @@ export function initRepoHistory() {
if (articleRequestToken.value !== currentToken) return;
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
+ syncArchivedNotice(doc);
const newSection = doc.querySelector('.history-view-section--article');
if (newSection && articleSection) {
articleSection.innerHTML = newSection.innerHTML;
diff --git a/web_src/js/index-domready.ts b/web_src/js/index-domready.ts
index 24da465173..7f46d4fb52 100644
--- a/web_src/js/index-domready.ts
+++ b/web_src/js/index-domready.ts
@@ -37,6 +37,7 @@ import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/use
import {initRepoRelease, initRepoReleaseNew} from './features/repo-release.ts';
import {initRepoEditor} from './features/repo-editor.ts';
import {initArticleEditor} from './features/article-editor.ts';
+import {initArticleSettings} from './features/article-settings.ts';
import {initCompSearchUserBox} from './features/comp/SearchUserBox.ts';
import {initInstall} from './features/install.ts';
import {initCompWebHookEditor} from './features/comp/WebHookEditor.ts';
@@ -133,6 +134,7 @@ const initPerformanceTracer = callInitFunctions([
initRepoDiffCommitBranchesAndTags,
initRepoEditor,
initArticleEditor,
+ initArticleSettings,
initRepoGraphGit,
initRepoIssueContentHistory,
initRepoIssueList,
+ {{if .Flash.SuccessMsg}}
+
+
+{{if not .Repository.IsArchived}}
+
+
+
+ {{end}}
+ {{if .Flash.ErrorMsg}}
+ {{.Flash.SuccessMsg | SanitizeHTML}}
+
+
+ {{end}}
+ {{.Flash.ErrorMsg | SanitizeHTML}}
+
+
+
+
+
+
+ {{ctx.Locale.Tr "repo.settings.sidebar_header"}}
+ +
+
+
+
+ {{if not .Repository.IsArchived}}
+ {{ctx.Locale.Tr "repo.settings.article_general"}}
+
+
+
+
+
+
+
+
+ {{ctx.AvatarUtils.Avatar .Repository.Owner 20}}
+ {{.Repository.Owner.Name}}
+
+
+
+
+
+ {{if and .Repository.IsFork .Repository.BaseRepo}}
+ {{.Repository.BaseRepo.OwnerName}} / {{$subjectName}}
+ {{else}}
+ -
+ {{end}}
+
+
+
+
+
+ {{$subjectName}}
+
+
+
+ {{ctx.Locale.Tr "repo.settings.article_transfer"}}
+
+ {{if .ArticleTransferRecipient}}
+
+ {{ctx.Locale.Tr "repo.settings.article_transfer_pending" .ArticleTransferRecipient.DisplayName}}
+ + {{else}} +{{ctx.Locale.Tr "repo.settings.article_transfer_desc"}}
+ + {{end}} +
+
+ {{end}}
+ {{ctx.Locale.Tr "repo.settings.article_archive"}}
+
+
+ {{ctx.Locale.Tr "repo.settings.article_archive_desc"}}
+ +
+
+ {{ctx.Locale.Tr "repo.settings.article_delete"}}
+
+
+ {{ctx.Locale.Tr "repo.settings.article_delete_desc"}}
+ +
+
+
+{{ctx.Locale.Tr "repo.settings.article_transfer_modal_header"}}
+
+
+
+ {{svg "octicon-alert" 16 "tw-mt-1 tw-flex-shrink-0"}}
+ {{ctx.Locale.Tr "repo.settings.article_transfer_notice"}}
+
+ {{ctx.Locale.Tr "repo.settings.article_transfer_modal_desc"}}
+ +
+
+{{end}}
+
+{{ctx.Locale.Tr "repo.settings.article_archive_modal_header"}}
+
+
+
+ {{svg "octicon-alert" 16 "tw-mt-1 tw-flex-shrink-0"}}
+ {{ctx.Locale.Tr "repo.settings.article_archive_notice"}}
+
+ + {{ctx.Locale.Tr "repo.settings.article_archive_modal_desc"}} + {{.Repository.OwnerName}}/{{$subjectName}} +
+ +
+
+{{end}}
+{{end}}
diff --git a/models/user/search.go b/models/user/search.go
index 6371715d45..068cf09e7e 100644
--- a/models/user/search.go
+++ b/models/user/search.go
@@ -44,6 +44,12 @@ type SearchUserOptions struct {
// they own at least one root (non-fork, non-empty) repository, own only forks, or
// own neither. Empty means no filtering.
RepoRole RepoRole
+
+ // ExcludeUserIDs filters out the given users. Empty means no filtering.
+ ExcludeUserIDs []int64
+ // ExcludeOwnersOfSubjectID filters out the users who already own a repository for
+ // the given subject. Zero means no filtering.
+ ExcludeOwnersOfSubjectID int64
}
// RepoRole classifies a user by the repositories they own
@@ -153,6 +159,16 @@ func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Sess
}
}
+ if len(opts.ExcludeUserIDs) > 0 {
+ cond = cond.And(builder.NotIn("`user`.id", opts.ExcludeUserIDs))
+ }
+
+ if opts.ExcludeOwnersOfSubjectID > 0 {
+ cond = cond.And(builder.NotIn("`user`.id",
+ builder.Select("owner_id").From("repository").
+ Where(builder.Eq{"subject_id": opts.ExcludeOwnersOfSubjectID})))
+ }
+
e := db.GetEngine(ctx)
if !opts.IsTwoFactorEnabled.Has() {
return e.Where(cond)
diff --git a/models/user/user.go b/models/user/user.go
index eed406da15..70bb32af1e 100644
--- a/models/user/user.go
+++ b/models/user/user.go
@@ -1055,6 +1055,21 @@ func GetUserByName(ctx context.Context, name string) (*User, error) {
return u, nil
}
+// GetUsersByFullName returns the individual users whose full name matches the given
+// one. The comparison is case-insensitive and full names are not unique, so the
+// caller has to handle the ambiguous case.
+func GetUsersByFullName(ctx context.Context, fullName string) ([]*User, error) {
+ fullName = strings.TrimSpace(fullName)
+ if fullName == "" {
+ return nil, nil
+ }
+ users := make([]*User, 0, 2)
+ return users, db.GetEngine(ctx).
+ Where("LOWER(full_name) = ?", strings.ToLower(fullName)).
+ And("type = ?", UserTypeIndividual).
+ Find(&users)
+}
+
// GetUserEmailsByNames returns a list of e-mails corresponds to names of users
// that have their email notifications set to enabled or onmention.
func GetUserEmailsByNames(ctx context.Context, names []string) []string {
diff --git a/routers/web/explore/repo.go b/routers/web/explore/repo.go
index 7d4801930a..8e46384090 100644
--- a/routers/web/explore/repo.go
+++ b/routers/web/explore/repo.go
@@ -612,7 +612,7 @@ func handleRepoHistoryFeed(ctx *context.Context) bool {
return false
}
-// prepareArticleView prepares data for the article view (README display with read/edit/history modes)
+// prepareArticleView prepares data for the article view (README display with read/edit/history/settings modes)
// refPath is the reference path for rendering (e.g., "branch/main" or "commit/abc123")
func prepareArticleView(ctx *context.Context, gitRepo *git.Repository, entries []*git.TreeEntry, refPath string) {
// Determine mode (read/edit/history)
@@ -624,8 +624,27 @@ func prepareArticleView(ctx *context.Context, gitRepo *git.Repository, entries [
ctx.Data["IsArticleModeRead"] = mode == "read"
ctx.Data["IsArticleModeEdit"] = mode == "edit"
ctx.Data["IsArticleModeHistory"] = mode == "history"
+ ctx.Data["IsArticleModeSettings"] = mode == "settings"
ctx.Data["ReadmeRequested"] = true
+ // The Settings tab is only rendered for the article owner, so ownership must be
+ // known in every mode (edit mode refines this via prepareArticleForkOnEditData).
+ isRepoOwner := ctx.Doer != nil && ctx.Repo.Repository.OwnerID == ctx.Doer.ID
+ ctx.Data["IsRepoOwner"] = isRepoOwner
+
+ // The settings tab swaps the Transfer section for a "Cancel transfer" one while a
+ // transfer awaits the recipient's confirmation.
+ if isRepoOwner && ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer {
+ transfer, err := repo_model.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository)
+ if err != nil {
+ log.Error("GetPendingRepositoryTransfer [%d]: %v", ctx.Repo.Repository.ID, err)
+ } else if err := transfer.LoadRecipient(ctx); err != nil {
+ log.Error("LoadRecipient [%d]: %v", transfer.ID, err)
+ } else {
+ ctx.Data["ArticleTransferRecipient"] = transfer.Recipient
+ }
+ }
+
// Find README.md file
readmeFile := findReadmeInEntries(entries)
if readmeFile == nil {
diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go
index 12fdbbacb6..d7dcc2e4d1 100644
--- a/routers/web/repo/setting/setting.go
+++ b/routers/web/repo/setting/setting.go
@@ -17,11 +17,13 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/indexer/code"
issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
"code.gitea.io/gitea/modules/indexer/stats"
"code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates"
@@ -31,6 +33,7 @@ import (
actions_service "code.gitea.io/gitea/services/actions"
asymkey_service "code.gitea.io/gitea/services/asymkey"
"code.gitea.io/gitea/services/context"
+ convert_service "code.gitea.io/gitea/services/convert"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/migrations"
mirror_service "code.gitea.io/gitea/services/mirror"
@@ -808,6 +811,127 @@ func handleSettingsPostConvertFork(ctx *context.Context) {
ctx.Redirect(repo.Link())
}
+// articleSettingsURL is where the article settings modals return to, the repository
+// settings page is not reachable from the article UI.
+func articleSettingsURL(ctx *context.Context) string {
+ return ctx.Repo.RepoLink + "?view=article&mode=settings"
+}
+
+// ArticleTransferCandidates searches the users the current article can be transferred
+// to, excluding its owner and everyone who already owns an article on the same subject.
+func ArticleTransferCandidates(ctx *context.Context) {
+ repo := ctx.Repo.Repository
+ users, _, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{
+ Actor: ctx.Doer,
+ Keyword: ctx.FormTrim("q"),
+ Type: user_model.UserTypeIndividual,
+ IsActive: optional.Some(true),
+ ExcludeUserIDs: []int64{repo.OwnerID},
+ ExcludeOwnersOfSubjectID: repo.SubjectID,
+ ListOptions: db.ListOptions{PageSize: setting.UI.MembersPagingNum},
+ })
+ if err != nil {
+ ctx.ServerError("SearchUsers", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, map[string]any{"data": convert_service.ToUsers(ctx, ctx.Doer, users)})
+}
+
+// resolveArticleTransferRecipient finds the transfer recipient by their first and
+// last name, which is what the article transfer modal asks for. Users without a full
+// name are listed by their username instead, so that is tried as a fallback. It
+// reports the failure through a flash message and returns nil when the name is unusable.
+func resolveArticleTransferRecipient(ctx *context.Context, name string) *user_model.User {
+ candidates, err := user_model.GetUsersByFullName(ctx, name)
+ if err != nil {
+ ctx.ServerError("GetUsersByFullName", err)
+ return nil
+ }
+
+ var recipient *user_model.User
+ switch len(candidates) {
+ case 0:
+ recipient, err = user_model.GetUserByName(ctx, strings.TrimSpace(name))
+ if err != nil {
+ if !user_model.IsErrUserNotExist(err) {
+ ctx.ServerError("GetUserByName", err)
+ return nil
+ }
+ ctx.Flash.Error(ctx.Tr("repo.settings.article_transfer_owner_not_found"))
+ return nil
+ }
+ case 1:
+ recipient = candidates[0]
+ default:
+ ctx.Flash.Error(ctx.Tr("repo.settings.article_transfer_owner_ambiguous"))
+ return nil
+ }
+
+ if recipient.ID == ctx.Repo.Owner.ID {
+ ctx.Flash.Error(ctx.Tr("repo.settings.article_transfer_owner_is_current"))
+ return nil
+ }
+ return recipient
+}
+
+// handleArticleSettingsPostTransfer serves the article transfer modal, which confirms
+// with "{{ctx.Locale.Tr "repo.settings.article_delete_modal_header"}}
+
+
+
+ {{svg "octicon-alert" 16 "tw-mt-1 tw-flex-shrink-0"}}
+
+ -
+
- {{ctx.Locale.Tr "repo.settings.article_delete_notices_1"}} +
- {{ctx.Locale.Tr "repo.settings.article_delete_notices_2" (printf "%s/%s" .Repository.OwnerName $subjectName)}} +
+ {{ctx.Locale.Tr "repo.settings.article_delete_modal_desc"}} + {{.Repository.OwnerName}}/{{$subjectName}} +
+ +