Merge branch 'main' of github.com:go-gitea/gitea into api-repo-actions

This commit is contained in:
chesterip 2023-08-23 10:14:18 -04:00
commit 38d1197a39
26 changed files with 326 additions and 222 deletions

View File

@ -81,7 +81,7 @@ docker run --entrypoint="" --rm -it gitea/act_runner:latest act_runner generate-
When you are using the docker image, you can specify the configuration file by using the `CONFIG_FILE` environment variable. Make sure that the file is mounted into the container as a volume:
```bash
docker run -v $(pwd)/config.yaml:/config.yaml -e CONFIG_FILE=/config.yaml ...
docker run -v $PWD/config.yaml:/config.yaml -e CONFIG_FILE=/config.yaml ...
```
You may notice the commands above are both incomplete, because it is not the time to run the act runner yet.
@ -157,8 +157,8 @@ If you are using the docker image, behaviour will be slightly different. Registr
```bash
docker run \
-v $(pwd)/config.yaml:/config.yaml \
-v $(pwd)/data:/data \
-v $PWD/config.yaml:/config.yaml \
-v $PWD/data:/data \
-v /var/run/docker.sock:/var/run/docker.sock \
-e CONFIG_FILE=/config.yaml \
-e GITEA_INSTANCE_URL=<instance_url> \

View File

@ -0,0 +1,35 @@
---
date: "2023-08-22T14:21:00+08:00"
title: "Usage: Multi-factor Authentication (MFA)"
slug: "multi-factor-authentication"
weight: 15
toc: false
draft: false
menu:
sidebar:
parent: "usage"
name: "Multi-factor Authentication (MFA)"
weight: 15
identifier: "multi-factor-authentication"
---
# Multi-factor Authentication (MFA)
Multi-factor Authentication (also referred to as MFA or 2FA) enhances security by requiring a time-sensitive set of credentials in addition to a password.
If a password were later to be compromised, logging into Gitea will not be possible without the additional credentials and the account would remain secure.
Gitea supports both TOTP (Time-based One-Time Password) tokens and FIDO-based hardware keys using the Webauthn API.
MFA can be configured within the "Security" tab of the user settings page.
## MFA Considerations
Enabling MFA on a user does affect how the Git HTTP protocol can be used with the Git CLI.
This interface does not support MFA, and trying to use a password normally will no longer be possible whilst MFA is enabled.
If SSH is not an option for Git operations, an access token can be generated within the "Applications" tab of the user settings page.
This access token can be used as if it were a password in order to allow the Git CLI to function over HTTP.
> **Warning** - By its very nature, an access token sidesteps the security benefits of MFA.
> It must be kept secure and should only be used as a last resort.
The Gitea API supports providing the relevant TOTP password in the `X-Gitea-OTP` header, as described in [API Usage](development/api-usage.md).
This should be used instead of an access token where possible.

View File

@ -116,70 +116,71 @@ func GetIssueStats(opts *IssuesOptions) (*IssueStats, error) {
func getIssueStatsChunk(opts *IssuesOptions, issueIDs []int64) (*IssueStats, error) {
stats := &IssueStats{}
countSession := func(opts *IssuesOptions, issueIDs []int64) *xorm.Session {
sess := db.GetEngine(db.DefaultContext).
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
if len(opts.RepoIDs) > 1 {
sess.In("issue.repo_id", opts.RepoIDs)
} else if len(opts.RepoIDs) == 1 {
sess.And("issue.repo_id = ?", opts.RepoIDs[0])
}
if len(issueIDs) > 0 {
sess.In("issue.id", issueIDs)
}
applyLabelsCondition(sess, opts)
applyMilestoneCondition(sess, opts)
applyProjectCondition(sess, opts)
if opts.AssigneeID > 0 {
applyAssigneeCondition(sess, opts.AssigneeID)
} else if opts.AssigneeID == db.NoConditionID {
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
}
if opts.PosterID > 0 {
applyPosterCondition(sess, opts.PosterID)
}
if opts.MentionedID > 0 {
applyMentionedCondition(sess, opts.MentionedID)
}
if opts.ReviewRequestedID > 0 {
applyReviewRequestedCondition(sess, opts.ReviewRequestedID)
}
if opts.ReviewedID > 0 {
applyReviewedCondition(sess, opts.ReviewedID)
}
switch opts.IsPull {
case util.OptionalBoolTrue:
sess.And("issue.is_pull=?", true)
case util.OptionalBoolFalse:
sess.And("issue.is_pull=?", false)
}
return sess
}
sess := db.GetEngine(db.DefaultContext).
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
var err error
stats.OpenCount, err = countSession(opts, issueIDs).
stats.OpenCount, err = applyIssuesOptions(sess, opts, issueIDs).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return stats, err
}
stats.ClosedCount, err = countSession(opts, issueIDs).
stats.ClosedCount, err = applyIssuesOptions(sess, opts, issueIDs).
And("issue.is_closed = ?", true).
Count(new(Issue))
return stats, err
}
func applyIssuesOptions(sess *xorm.Session, opts *IssuesOptions, issueIDs []int64) *xorm.Session {
if len(opts.RepoIDs) > 1 {
sess.In("issue.repo_id", opts.RepoIDs)
} else if len(opts.RepoIDs) == 1 {
sess.And("issue.repo_id = ?", opts.RepoIDs[0])
}
if len(issueIDs) > 0 {
sess.In("issue.id", issueIDs)
}
applyLabelsCondition(sess, opts)
applyMilestoneCondition(sess, opts)
applyProjectCondition(sess, opts)
if opts.AssigneeID > 0 {
applyAssigneeCondition(sess, opts.AssigneeID)
} else if opts.AssigneeID == db.NoConditionID {
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
}
if opts.PosterID > 0 {
applyPosterCondition(sess, opts.PosterID)
}
if opts.MentionedID > 0 {
applyMentionedCondition(sess, opts.MentionedID)
}
if opts.ReviewRequestedID > 0 {
applyReviewRequestedCondition(sess, opts.ReviewRequestedID)
}
if opts.ReviewedID > 0 {
applyReviewedCondition(sess, opts.ReviewedID)
}
switch opts.IsPull {
case util.OptionalBoolTrue:
sess.And("issue.is_pull=?", true)
case util.OptionalBoolFalse:
sess.And("issue.is_pull=?", false)
}
return sess
}
// GetUserIssueStats returns issue statistic information for dashboard by given conditions.
func GetUserIssueStats(filterMode int, opts IssuesOptions) (*IssueStats, error) {
if opts.User == nil {

View File

@ -113,10 +113,11 @@ func (l *Label) CalOpenIssues() {
// SetArchived set the label as archived
func (l *Label) SetArchived(isArchived bool) {
if isArchived && l.ArchivedUnix.IsZero() {
l.ArchivedUnix = timeutil.TimeStampNow()
} else {
if !isArchived {
l.ArchivedUnix = timeutil.TimeStamp(0)
} else if isArchived && l.ArchivedUnix.IsZero() {
// Only change the date when it is newly archived.
l.ArchivedUnix = timeutil.TimeStampNow()
}
}

View File

@ -130,6 +130,10 @@ type SearchRepoOptions struct {
// True -> include just collaborative
// False -> include just non-collaborative
Collaborate util.OptionalBool
// What type of unit the user can be collaborative in,
// it is ignored if Collaborate is False.
// TypeInvalid means any unit type.
UnitType unit.Type
// None -> include forks AND non-forks
// True -> include just forks
// False -> include just non-forks
@ -382,19 +386,25 @@ func SearchRepositoryCondition(opts *SearchRepoOptions) builder.Cond {
if opts.Collaborate != util.OptionalBoolFalse {
// A Collaboration is:
collaborateCond := builder.And(
// 1. Repository we don't own
builder.Neq{"owner_id": opts.OwnerID},
// 2. But we can see because of:
builder.Or(
// A. We have unit independent access
UserAccessRepoCond("`repository`.id", opts.OwnerID),
// B. We are in a team for
UserOrgTeamRepoCond("`repository`.id", opts.OwnerID),
// C. Public repositories in organizations that we are member of
userOrgPublicRepoCondPrivate(opts.OwnerID),
),
)
collaborateCond := builder.NewCond()
// 1. Repository we don't own
collaborateCond = collaborateCond.And(builder.Neq{"owner_id": opts.OwnerID})
// 2. But we can see because of:
{
userAccessCond := builder.NewCond()
// A. We have unit independent access
userAccessCond = userAccessCond.Or(UserAccessRepoCond("`repository`.id", opts.OwnerID))
// B. We are in a team for
if opts.UnitType == unit.TypeInvalid {
userAccessCond = userAccessCond.Or(UserOrgTeamRepoCond("`repository`.id", opts.OwnerID))
} else {
userAccessCond = userAccessCond.Or(userOrgTeamUnitRepoCond("`repository`.id", opts.OwnerID, opts.UnitType))
}
// C. Public repositories in organizations that we are member of
userAccessCond = userAccessCond.Or(userOrgPublicRepoCondPrivate(opts.OwnerID))
collaborateCond = collaborateCond.And(userAccessCond)
}
if !opts.Private {
collaborateCond = collaborateCond.And(builder.Expr("owner_id NOT IN (SELECT org_id FROM org_user WHERE org_user.uid = ? AND org_user.is_public = ?)", opts.OwnerID, false))
}

View File

@ -13,6 +13,7 @@ import (
db_model "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/indexer/issues/bleve"
"code.gitea.io/gitea/modules/indexer/issues/db"
@ -277,7 +278,7 @@ func IsAvailable(ctx context.Context) bool {
}
// SearchOptions indicates the options for searching issues
type SearchOptions internal.SearchOptions
type SearchOptions = internal.SearchOptions
const (
SortByCreatedDesc = internal.SortByCreatedDesc
@ -291,7 +292,6 @@ const (
)
// SearchIssues search issues by options.
// It returns issue ids and a bool value indicates if the result is imprecise.
func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, error) {
indexer := *globalIndexer.Load()
@ -305,7 +305,7 @@ func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, err
indexer = db.NewIndexer()
}
result, err := indexer.Search(ctx, (*internal.SearchOptions)(opts))
result, err := indexer.Search(ctx, opts)
if err != nil {
return nil, 0, err
}
@ -317,3 +317,38 @@ func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, err
return ret, result.Total, nil
}
// CountIssues counts issues by options. It is a shortcut of SearchIssues(ctx, opts) but only returns the total count.
func CountIssues(ctx context.Context, opts *SearchOptions) (int64, error) {
opts = opts.Copy(func(options *SearchOptions) { opts.Paginator = &db_model.ListOptions{PageSize: 0} })
_, total, err := SearchIssues(ctx, opts)
return total, err
}
// CountIssuesByRepo counts issues by options and group by repo id.
// It's not a complete implementation, since it requires the caller should provide the repo ids.
// That means opts.RepoIDs must be specified, and opts.AllPublic must be false.
// It's good enough for the current usage, and it can be improved if needed.
// TODO: use "group by" of the indexer engines to implement it.
func CountIssuesByRepo(ctx context.Context, opts *SearchOptions) (map[int64]int64, error) {
if len(opts.RepoIDs) == 0 {
return nil, fmt.Errorf("opts.RepoIDs must be specified")
}
if opts.AllPublic {
return nil, fmt.Errorf("opts.AllPublic must be false")
}
repoIDs := container.SetOf(opts.RepoIDs...).Values()
ret := make(map[int64]int64, len(repoIDs))
// TODO: it could be faster if do it in parallel for some indexer engines. Improve it if users report it's slow.
for _, repoID := range repoIDs {
count, err := CountIssues(ctx, opts.Copy(func(o *internal.SearchOptions) { o.RepoIDs = []int64{repoID} }))
if err != nil {
return nil, err
}
ret[repoID] = count
}
return ret, nil
}

View File

@ -109,6 +109,19 @@ type SearchOptions struct {
SortBy SortBy // sort by field
}
// Copy returns a copy of the options.
// Be careful, it's not a deep copy, so `SearchOptions.RepoIDs = {...}` is OK while `SearchOptions.RepoIDs[0] = ...` is not.
func (o *SearchOptions) Copy(edit ...func(options *SearchOptions)) *SearchOptions {
if o == nil {
return nil
}
v := *o
for _, e := range edit {
e(&v)
}
return &v
}
type SortBy string
const (

View File

@ -480,5 +480,5 @@ func DeleteAvatar(ctx *context.Context) {
ctx.Flash.Error(err.Error())
}
ctx.Redirect(setting.AppSubURL + "/admin/users/" + strconv.FormatInt(u.ID, 10))
ctx.JSONRedirect(setting.AppSubURL + "/admin/users/" + strconv.FormatInt(u.ID, 10))
}

View File

@ -156,7 +156,7 @@ func SettingsDeleteAvatar(ctx *context.Context) {
ctx.Flash.Error(err.Error())
}
ctx.Redirect(ctx.Org.OrgLink + "/settings")
ctx.JSONRedirect(ctx.Org.OrgLink + "/settings")
}
// SettingsDelete response for deleting an organization

View File

@ -72,5 +72,5 @@ func SettingsDeleteAvatar(ctx *context.Context) {
if err := repo_service.DeleteAvatar(ctx, ctx.Repo.Repository); err != nil {
ctx.Flash.Error(fmt.Sprintf("DeleteAvatar: %v", err))
}
ctx.Redirect(ctx.Repo.RepoLink + "/settings")
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings")
}

View File

@ -448,21 +448,26 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// - Team org's owns the repository.
// - Team has read permission to repository.
repoOpts := &repo_model.SearchRepoOptions{
Actor: ctx.Doer,
OwnerID: ctx.Doer.ID,
Private: true,
AllPublic: false,
AllLimited: false,
Actor: ctx.Doer,
OwnerID: ctx.Doer.ID,
Private: true,
AllPublic: false,
AllLimited: false,
Collaborate: util.OptionalBoolNone,
UnitType: unitType,
Archived: util.OptionalBoolFalse,
}
if team != nil {
repoOpts.TeamID = team.ID
}
accessibleRepos := container.Set[int64]{}
{
ids, _, err := repo_model.SearchRepositoryIDs(repoOpts)
if err != nil {
ctx.ServerError("SearchRepositoryIDs", err)
return
}
accessibleRepos.AddMultiple(ids...)
opts.RepoIDs = ids
if len(opts.RepoIDs) == 0 {
// no repos found, don't let the indexer return all repos
@ -489,40 +494,16 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
keyword := strings.Trim(ctx.FormString("q"), " ")
ctx.Data["Keyword"] = keyword
accessibleRepos := container.Set[int64]{}
{
ids, err := issues_model.GetRepoIDsForIssuesOptions(opts, ctxUser)
if err != nil {
ctx.ServerError("GetRepoIDsForIssuesOptions", err)
return
}
for _, id := range ids {
accessibleRepos.Add(id)
}
}
// Educated guess: Do or don't show closed issues.
isShowClosed := ctx.FormString("state") == "closed"
opts.IsClosed = util.OptionalBoolOf(isShowClosed)
// Filter repos and count issues in them. Count will be used later.
// USING NON-FINAL STATE OF opts FOR A QUERY.
var issueCountByRepo map[int64]int64
{
issueIDs, err := issueIDsFromSearch(ctx, keyword, opts)
if err != nil {
ctx.ServerError("issueIDsFromSearch", err)
return
}
if len(issueIDs) > 0 { // else, no issues found, just leave issueCountByRepo empty
opts.IssueIDs = issueIDs
issueCountByRepo, err = issues_model.CountIssuesByRepo(ctx, opts)
if err != nil {
ctx.ServerError("CountIssuesByRepo", err)
return
}
opts.IssueIDs = nil // reset, the opts will be used later
}
issueCountByRepo, err := issue_indexer.CountIssuesByRepo(ctx, issue_indexer.ToSearchOptions(keyword, opts))
if err != nil {
ctx.ServerError("CountIssuesByRepo", err)
return
}
// Make sure page number is at least 1. Will be posted to ctx.Data.
@ -551,13 +532,13 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// Parse ctx.FormString("repos") and remember matched repo IDs for later.
// Gets set when clicking filters on the issues overview page.
repoIDs := getRepoIDs(ctx.FormString("repos"))
if len(repoIDs) > 0 {
// Remove repo IDs that are not accessible to the user.
repoIDs = util.SliceRemoveAllFunc(repoIDs, func(v int64) bool {
return !accessibleRepos.Contains(v)
})
opts.RepoIDs = repoIDs
selectedRepoIDs := getRepoIDs(ctx.FormString("repos"))
// Remove repo IDs that are not accessible to the user.
selectedRepoIDs = util.SliceRemoveAllFunc(selectedRepoIDs, func(v int64) bool {
return !accessibleRepos.Contains(v)
})
if len(selectedRepoIDs) > 0 {
opts.RepoIDs = selectedRepoIDs
}
// ------------------------------
@ -568,7 +549,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// USING FINAL STATE OF opts FOR A QUERY.
var issues issues_model.IssueList
{
issueIDs, err := issueIDsFromSearch(ctx, keyword, opts)
issueIDs, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts))
if err != nil {
ctx.ServerError("issueIDsFromSearch", err)
return
@ -584,6 +565,18 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// Add repository pointers to Issues.
// ----------------------------------
// Remove repositories that should not be shown,
// which are repositories that have no issues and are not selected by the user.
selectedReposMap := make(map[int64]struct{}, len(selectedRepoIDs))
for _, repoID := range selectedRepoIDs {
selectedReposMap[repoID] = struct{}{}
}
for k, v := range issueCountByRepo {
if _, ok := selectedReposMap[k]; !ok && v == 0 {
delete(issueCountByRepo, k)
}
}
// showReposMap maps repository IDs to their Repository pointers.
showReposMap, err := loadRepoByIDs(ctxUser, issueCountByRepo, unitType)
if err != nil {
@ -615,44 +608,10 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// -------------------------------
// Fill stats to post to ctx.Data.
// -------------------------------
var issueStats *issues_model.IssueStats
{
statsOpts := issues_model.IssuesOptions{
RepoIDs: repoIDs,
User: ctx.Doer,
IsPull: util.OptionalBoolOf(isPullList),
IsClosed: util.OptionalBoolOf(isShowClosed),
IssueIDs: nil,
IsArchived: util.OptionalBoolFalse,
LabelIDs: opts.LabelIDs,
Org: org,
Team: team,
RepoCond: opts.RepoCond,
}
if keyword != "" {
statsOpts.RepoIDs = opts.RepoIDs
allIssueIDs, err := issueIDsFromSearch(ctx, keyword, &statsOpts)
if err != nil {
ctx.ServerError("issueIDsFromSearch", err)
return
}
statsOpts.IssueIDs = allIssueIDs
}
if keyword != "" && len(statsOpts.IssueIDs) == 0 {
// So it did search with the keyword, but no issue found.
// Just set issueStats to empty.
issueStats = &issues_model.IssueStats{}
} else {
// So it did search with the keyword, and found some issues. It needs to get issueStats of these issues.
// Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts.
issueStats, err = issues_model.GetUserIssueStats(filterMode, statsOpts)
if err != nil {
ctx.ServerError("GetUserIssueStats", err)
return
}
}
issueStats, err := getUserIssueStats(ctx, filterMode, issue_indexer.ToSearchOptions(keyword, opts), ctx.Doer.ID)
if err != nil {
ctx.ServerError("getUserIssueStats", err)
return
}
// Will be posted to ctx.Data.
@ -722,7 +681,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
ctx.Data["IssueStats"] = issueStats
ctx.Data["ViewType"] = viewType
ctx.Data["SortType"] = sortType
ctx.Data["RepoIDs"] = opts.RepoIDs
ctx.Data["RepoIDs"] = selectedRepoIDs
ctx.Data["IsShowClosed"] = isShowClosed
ctx.Data["SelectLabels"] = selectedLabels
@ -777,14 +736,6 @@ func getRepoIDs(reposQuery string) []int64 {
return repoIDs
}
func issueIDsFromSearch(ctx *context.Context, keyword string, opts *issues_model.IssuesOptions) ([]int64, error) {
ids, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts))
if err != nil {
return nil, fmt.Errorf("SearchIssues: %w", err)
}
return ids, nil
}
func loadRepoByIDs(ctxUser *user_model.User, issueCountByRepo map[int64]int64, unitType unit.Type) (map[int64]*repo_model.Repository, error) {
totalRes := make(map[int64]*repo_model.Repository, len(issueCountByRepo))
repoIDs := make([]int64, 0, 500)
@ -913,3 +864,71 @@ func UsernameSubRoute(ctx *context.Context) {
}
}
}
func getUserIssueStats(ctx *context.Context, filterMode int, opts *issue_indexer.SearchOptions, doerID int64) (*issues_model.IssueStats, error) {
opts = opts.Copy(func(o *issue_indexer.SearchOptions) {
o.AssigneeID = nil
o.PosterID = nil
o.MentionID = nil
o.ReviewRequestedID = nil
o.ReviewedID = nil
})
var (
err error
ret = &issues_model.IssueStats{}
)
{
openClosedOpts := opts.Copy()
switch filterMode {
case issues_model.FilterModeAll, issues_model.FilterModeYourRepositories:
case issues_model.FilterModeAssign:
openClosedOpts.AssigneeID = &doerID
case issues_model.FilterModeCreate:
openClosedOpts.PosterID = &doerID
case issues_model.FilterModeMention:
openClosedOpts.MentionID = &doerID
case issues_model.FilterModeReviewRequested:
openClosedOpts.ReviewRequestedID = &doerID
case issues_model.FilterModeReviewed:
openClosedOpts.ReviewedID = &doerID
}
openClosedOpts.IsClosed = util.OptionalBoolFalse
ret.OpenCount, err = issue_indexer.CountIssues(ctx, openClosedOpts)
if err != nil {
return nil, err
}
openClosedOpts.IsClosed = util.OptionalBoolTrue
ret.ClosedCount, err = issue_indexer.CountIssues(ctx, openClosedOpts)
if err != nil {
return nil, err
}
}
ret.YourRepositoriesCount, err = issue_indexer.CountIssues(ctx, opts)
if err != nil {
return nil, err
}
ret.AssignCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AssigneeID = &doerID }))
if err != nil {
return nil, err
}
ret.CreateCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.PosterID = &doerID }))
if err != nil {
return nil, err
}
ret.MentionCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.MentionID = &doerID }))
if err != nil {
return nil, err
}
ret.ReviewRequestedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewRequestedID = &doerID }))
if err != nil {
return nil, err
}
ret.ReviewedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewedID = &doerID }))
if err != nil {
return nil, err
}
return ret, nil
}

View File

@ -194,7 +194,7 @@ func DeleteAvatar(ctx *context.Context) {
ctx.Flash.Error(err.Error())
}
ctx.Redirect(setting.AppSubURL + "/user/settings")
ctx.JSONRedirect(setting.AppSubURL + "/user/settings")
}
// Organization render all the organization of the user

View File

@ -186,7 +186,7 @@
<div class="field">
<button class="ui green button">{{$.locale.Tr "settings.update_avatar"}}</button>
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete" data-redirect="{{.Link}}">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
</div>
</form>
</div>

View File

@ -48,6 +48,7 @@
</div>
<div class="flex-item-body">
Very loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong content
<span class="text truncate">Truncate very loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong content</span>
</div>
</div>
<div class="flex-item-trailing">

View File

@ -94,7 +94,7 @@
<div class="field">
<button class="ui green button">{{$.locale.Tr "settings.update_avatar"}}</button>
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete" data-redirect="{{.Link}}">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
</div>
</form>
</div>

View File

@ -67,7 +67,7 @@
<div class="js-branch-tag-selector {{if .ContainerClasses}}{{.ContainerClasses}}{{end}}">
{{/* show dummy elements before Vue componment is mounted, this code must match the code in BranchTagSelector.vue */}}
<div class="ui floating filter dropdown custom">
<div class="ui dropdown custom">
<button class="branch-dropdown-button gt-ellipsis ui basic small compact button gt-df gt-m-0">
<span class="text gt-df gt-ac gt-mr-2">
{{if .release}}

View File

@ -58,7 +58,7 @@
</div>
<div class="field">
<button class="ui green button">{{$.locale.Tr "settings.update_avatar"}}</button>
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete" data-redirect="{{.Link}}">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
</div>
</form>
</div>

View File

@ -80,25 +80,21 @@
{{end}}
</div>
{{if or (eq .GetOpType 5) (eq .GetOpType 18)}}
<div class="gt-pl-5">
{{$push := ActionContent2Commits .}}
{{$repoLink := .GetRepoLink}}
{{range $push.Commits}}
{{$commitLink := printf "%s/commit/%s" $repoLink .Sha1}}
<div class="flex-item">
<img class="ui avatar" src="{{$push.AvatarLink $.Context .AuthorEmail}}" title="{{.AuthorName}}" width="16" height="16">
<a class="commit-id" href="{{$commitLink}}">{{ShortSha .Sha1}}</a>
<span class="text truncate light grey">
{{RenderCommitMessage $.Context .Message $repoLink $.ComposeMetas}}
</span>
</div>
{{end}}
{{if and (gt $push.Len 1) $push.CompareURL}}
<div class="flex-item">
<a href="{{AppSubUrl}}/{{$push.CompareURL}}">{{$.locale.Tr "action.compare_commits" $push.Len}} Β»</a>
</div>
{{end}}
</div>
{{$push := ActionContent2Commits .}}
{{$repoLink := .GetRepoLink}}
{{range $push.Commits}}
{{$commitLink := printf "%s/commit/%s" $repoLink .Sha1}}
<div class="flex-text-block">
<img class="ui avatar" src="{{$push.AvatarLink $.Context .AuthorEmail}}" title="{{.AuthorName}}" width="16" height="16">
<a class="gt-mono" href="{{$commitLink}}">{{ShortSha .Sha1}}</a>
<span class="text truncate light grey">
{{RenderCommitMessage $.Context .Message $repoLink $.ComposeMetas}}
</span>
</div>
{{end}}
{{if and (gt $push.Len 1) $push.CompareURL}}
<a href="{{AppSubUrl}}/{{$push.CompareURL}}">{{$.locale.Tr "action.compare_commits" $push.Len}} Β»</a>
{{end}}
{{else if eq .GetOpType 6}}
<span class="text truncate issue title">{{index .GetIssueInfos 1 | RenderEmoji $.Context | RenderCodeBlock}}</span>
{{else if eq .GetOpType 7}}

View File

@ -5,29 +5,29 @@
<div class="ui stackable grid">
<div class="four wide column">
<div class="ui secondary vertical filter menu gt-bg-transparent">
<a class="{{if eq .ViewType "your_repositories"}}active{{end}} item" href="{{.Link}}?type=your_repositories&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
<a class="{{if eq .ViewType "your_repositories"}}active{{end}} item" href="{{.Link}}?type=your_repositories&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
{{.locale.Tr "home.issues.in_your_repos"}}
<strong class="ui right">{{CountFmt .IssueStats.YourRepositoriesCount}}</strong>
</a>
<a class="{{if eq .ViewType "assigned"}}active{{end}} item" href="{{.Link}}?type=assigned&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
<a class="{{if eq .ViewType "assigned"}}active{{end}} item" href="{{.Link}}?type=assigned&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
{{.locale.Tr "repo.issues.filter_type.assigned_to_you"}}
<strong class="ui right">{{CountFmt .IssueStats.AssignCount}}</strong>
</a>
<a class="{{if eq .ViewType "created_by"}}active{{end}} item" href="{{.Link}}?type=created_by&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
<a class="{{if eq .ViewType "created_by"}}active{{end}} item" href="{{.Link}}?type=created_by&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
{{.locale.Tr "repo.issues.filter_type.created_by_you"}}
<strong class="ui right">{{CountFmt .IssueStats.CreateCount}}</strong>
</a>
{{if .PageIsPulls}}
<a class="{{if eq .ViewType "review_requested"}}active{{end}} item" href="{{.Link}}?type=review_requested&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
<a class="{{if eq .ViewType "review_requested"}}active{{end}} item" href="{{.Link}}?type=review_requested&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
{{.locale.Tr "repo.issues.filter_type.review_requested"}}
<strong class="ui right">{{CountFmt .IssueStats.ReviewRequestedCount}}</strong>
</a>
<a class="{{if eq .ViewType "reviewed_by"}}active{{end}} item" href="{{.Link}}?type=reviewed_by&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
<a class="{{if eq .ViewType "reviewed_by"}}active{{end}} item" href="{{.Link}}?type=reviewed_by&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
{{.locale.Tr "repo.issues.filter_type.reviewed_by_you"}}
<strong class="ui right">{{CountFmt .IssueStats.ReviewedCount}}</strong>
</a>
{{end}}
<a class="{{if eq .ViewType "mentioned"}}active{{end}} item" href="{{.Link}}?type=mentioned&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
<a class="{{if eq .ViewType "mentioned"}}active{{end}} item" href="{{.Link}}?type=mentioned&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
{{.locale.Tr "repo.issues.filter_type.mentioning_you"}}
<strong class="ui right">{{CountFmt .IssueStats.MentionCount}}</strong>
</a>

View File

@ -126,7 +126,7 @@
<div class="field">
<button class="ui green button">{{$.locale.Tr "settings.update_avatar"}}</button>
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete" data-redirect="{{.Link}}">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
</div>
</form>
</div>

View File

@ -96,10 +96,6 @@
}
}
.feeds .commit-id {
font-family: var(--fonts-monospace);
}
.feeds code {
padding: 2px 4px;
border-radius: 3px;

View File

@ -29,7 +29,8 @@
display: flex;
flex-direction: column;
flex-grow: 1;
flex-basis: 60%;
flex-basis: 60%; /* avoid wrapping the "flex-item-trailing" too aggressively */
min-width: 0; /* make the "text truncate" work, otherwise the flex axis is not limited and the text just overflows */
}
.flex-item-header {
@ -66,6 +67,7 @@
font-size: 16px;
font-weight: var(--font-weight-semibold);
word-break: break-word;
min-width: 0;
}
.flex-item .flex-item-title a {

View File

@ -1,5 +1,5 @@
<template>
<div class="ui floating filter dropdown custom">
<div class="ui dropdown custom">
<button class="branch-dropdown-button gt-ellipsis ui basic small compact button gt-df gt-m-0" @click="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
<span class="text gt-df gt-ac gt-mr-2">
<template v-if="release">{{ textReleaseCompare }}</template>

View File

@ -95,14 +95,14 @@ async function fetchActionDoRequest(actionElem, url, opt) {
const data = await resp.json();
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
await showErrorToast(data.errorMessage || `server error: ${resp.status}`);
showErrorToast(data.errorMessage || `server error: ${resp.status}`);
} else {
await showErrorToast(`server error: ${resp.status}`);
showErrorToast(`server error: ${resp.status}`);
}
} catch (e) {
console.error('error when doRequest', e);
actionElem.classList.remove('is-loading', 'small-loading-icon');
await showErrorToast(i18n.network_error);
showErrorToast(i18n.network_error);
}
}

View File

@ -1,6 +1,6 @@
import {htmlEscape} from 'escape-goat';
import {svg} from '../svg.js';
import Toastify from 'toastify-js';
import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown
const levels = {
info: {
@ -21,9 +21,7 @@ const levels = {
};
// See https://github.com/apvarun/toastify-js#api for options
async function showToast(message, level, {gravity, position, duration, ...other} = {}) {
if (!message) return;
function showToast(message, level, {gravity, position, duration, ...other} = {}) {
const {icon, background, duration: levelDuration} = levels[level ?? 'info'];
const toast = Toastify({
@ -41,20 +39,17 @@ async function showToast(message, level, {gravity, position, duration, ...other}
});
toast.showToast();
toast.toastElement.querySelector('.toast-close').addEventListener('click', () => {
toast.removeElement(toast.toastElement);
});
toast.toastElement.querySelector('.toast-close').addEventListener('click', () => toast.hideToast());
}
export async function showInfoToast(message, opts) {
return await showToast(message, 'info', opts);
export function showInfoToast(message, opts) {
return showToast(message, 'info', opts);
}
export async function showWarningToast(message, opts) {
return await showToast(message, 'warning', opts);
export function showWarningToast(message, opts) {
return showToast(message, 'warning', opts);
}
export async function showErrorToast(message, opts) {
return await showToast(message, 'error', opts);
export function showErrorToast(message, opts) {
return showToast(message, 'error', opts);
}

View File

@ -2,16 +2,16 @@ import {test, expect} from 'vitest';
import {showInfoToast, showErrorToast, showWarningToast} from './toast.js';
test('showInfoToast', async () => {
await showInfoToast('success πŸ˜€', {duration: -1});
showInfoToast('success πŸ˜€', {duration: -1});
expect(document.querySelector('.toastify')).toBeTruthy();
});
test('showWarningToast', async () => {
await showWarningToast('warning 😐', {duration: -1});
showWarningToast('warning 😐', {duration: -1});
expect(document.querySelector('.toastify')).toBeTruthy();
});
test('showErrorToast', async () => {
await showErrorToast('error πŸ™', {duration: -1});
showErrorToast('error πŸ™', {duration: -1});
expect(document.querySelector('.toastify')).toBeTruthy();
});