+ {{/* Rendered outside the article section so it stays visible on every article + mode (read, edit, history, settings); repo-history.ts keeps it in sync when the + article section is swapped or another view is selected. */}} + {{/* tw-hidden, not the hidden attribute: tw-flex would otherwise win over the UA [hidden] rule */}} +
+ {{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}} +
+
+ {{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))}} +
+
+
+ {{.CsrfTokenHtml}} + +
+
+ {{.CsrfTokenHtml}} + +
+
+
+ {{end}} + {{/* Login banner for non-authenticated users */}} {{if and .IsArticleModeEdit (not .IsSigned)}}
@@ -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}}
{{if .IsArticleModeRead}}
{{end}} - {{if .IsArticleModeEdit}} + {{if and .IsArticleModeEdit (not .Repository.IsArchived)}}
{{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/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)}}
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}} +
+ {{if .Flash.SuccessMsg}} +
+

{{.Flash.SuccessMsg | SanitizeHTML}}

+
+ {{end}} + {{if .Flash.ErrorMsg}} +
+

{{.Flash.ErrorMsg | SanitizeHTML}}

+
+ {{end}} +
+
+
+

{{ctx.Locale.Tr "repo.settings.sidebar_header"}}

+ +
+
+
+
+

{{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}} +
+
+
+
+ {{if not .Repository.IsArchived}} +
+

{{ctx.Locale.Tr "repo.settings.article_transfer"}}

+
+ {{if .ArticleTransferRecipient}} +

{{ctx.Locale.Tr "repo.settings.article_transfer_pending" .ArticleTransferRecipient.DisplayName}}

+
+ {{.CsrfTokenHtml}} + + + + +
+ {{else}} +

{{ctx.Locale.Tr "repo.settings.article_transfer_desc"}}

+ + {{end}} +
+
+
+

{{ctx.Locale.Tr "repo.settings.article_archive"}}

+
+

{{ctx.Locale.Tr "repo.settings.article_archive_desc"}}

+ +
+
+ {{end}} +
+

{{ctx.Locale.Tr "repo.settings.article_delete"}}

+
+

{{ctx.Locale.Tr "repo.settings.article_delete_desc"}}

+ +
+
+
+
+
+ +{{if not .Repository.IsArchived}} + +
+
{{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"}}

+
+ {{.CsrfTokenHtml}} + + + +
+ +
+
+ +
+
+
+ + +
+ +
+
+
+

+ {{ctx.Locale.Tr "repo.settings.article_transfer_modal_confirm_desc"}} + {{.Repository.OwnerName}}/{{$subjectName}} +

+
+ + +
+
+ + +
+
+
+
+ +
+
{{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}} +

+
+ {{.CsrfTokenHtml}} + + + +
+ + +
+
+ + +
+
+
+
+{{end}} + +
+
{{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}} +

+
+ {{.CsrfTokenHtml}} + + + +
+ + +
+
+ + +
+
+
+
+{{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 "/", 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,