fix(web): show session refusals in the async merge box and keep a single merge error alert
Gate refusals on htmx merge posts render a merge-box status fragment instead of plain text, the merge error alert inside the action area only renders in fragments, and the checks-label observer test targets the stable MR bar.
5 files · +71 -14
M internal/adapters/web/jstest/mr.test.mjs +12 -4
| @@ -152,8 +152,10 @@ test('footer tabs: closed by default, toggle, switch, arrows, hash', () => { | |||
| 152 | 152 | test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => { | |
| 153 | 153 | const text0 = { v: '' }; let writes = 0; | |
| 154 | 154 | const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } }; | |
| 155 | - | const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} }; | |
| 156 | - | const h = footerHarness({ summary }); | |
| 155 | + | const bar = {}; | |
| 156 | + | const summary = { title: '1/2 failed', textContent: ' 1/2 ', closest: (s) => s === '.mr-bar' ? bar : null }; | |
| 157 | + | const extra = { summary }; | |
| 158 | + | const h = footerHarness(extra); | |
| 157 | 159 | let observed = null, callback = null; | |
| 158 | 160 | globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } }; | |
| 159 | 161 | const nav = document.querySelector('[data-mr-tabs]'); | |
| @@ -161,8 +163,14 @@ test('footer: #mr-ck syncs the checks label; its click opens Checks without togg | |||
| 161 | 163 | try { | |
| 162 | 164 | window.ermiteMR.panels(); | |
| 163 | 165 | assert.equal(label.textContent, '1/2 failed', 'initial label sync'); | |
| 164 | - | assert.equal(observed[0], summary.parentElement); | |
| 165 | - | assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer'); | |
| 166 | + | assert.equal(observed[0], bar); | |
| 167 | + | assert.deepEqual(observed[1], { childList: true, subtree: true }, 'the stable bar observes action and badge replacements'); | |
| 168 | + | // Replace #mr-actions, including its badge, then replace the badge alone. | |
| 169 | + | extra.summary = { title: 'checks running', textContent: '' }; callback(); | |
| 170 | + | assert.equal(label.textContent, 'checks running'); | |
| 171 | + | extra.summary = { title: 'checks passed', textContent: '' }; callback(); | |
| 172 | + | assert.equal(label.textContent, 'checks passed'); | |
| 173 | + | extra.summary = summary; callback(); | |
| 166 | 174 | const before = writes; callback(); callback(); | |
| 167 | 175 | assert.equal(writes, before, 'an unchanged label is not written'); | |
| 168 | 176 | summary.title = ''; callback(); | |
| @@ -152,8 +152,10 @@ test('footer tabs: closed by default, toggle, switch, arrows, hash', () => { | |||||
| 152 | test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => { | 152 | test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => { | ||
| 153 | const text0 = { v: '' }; let writes = 0; | 153 | const text0 = { v: '' }; let writes = 0; | ||
| 154 | const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } }; | 154 | const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } }; | ||
| 155 | - | const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} }; | 155 | + | const bar = {}; |
| 156 | - | const h = footerHarness({ summary }); | 156 | + | const summary = { title: '1/2 failed', textContent: ' 1/2 ', closest: (s) => s === '.mr-bar' ? bar : null }; |
| 157 | + | const extra = { summary }; | |||
| 158 | + | const h = footerHarness(extra); | |||
| 157 | let observed = null, callback = null; | 159 | let observed = null, callback = null; | ||
| 158 | globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } }; | 160 | globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } }; | ||
| 159 | const nav = document.querySelector('[data-mr-tabs]'); | 161 | const nav = document.querySelector('[data-mr-tabs]'); | ||
| @@ -161,8 +163,14 @@ test('footer: #mr-ck syncs the checks label; its click opens Checks without togg | |||||
| 161 | try { | 163 | try { | ||
| 162 | window.ermiteMR.panels(); | 164 | window.ermiteMR.panels(); | ||
| 163 | assert.equal(label.textContent, '1/2 failed', 'initial label sync'); | 165 | assert.equal(label.textContent, '1/2 failed', 'initial label sync'); | ||
| 164 | - | assert.equal(observed[0], summary.parentElement); | 166 | + | assert.equal(observed[0], bar); |
| 165 | - | assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer'); | 167 | + | assert.deepEqual(observed[1], { childList: true, subtree: true }, 'the stable bar observes action and badge replacements'); |
| 168 | + | // Replace #mr-actions, including its badge, then replace the badge alone. | |||
| 169 | + | extra.summary = { title: 'checks running', textContent: '' }; callback(); | |||
| 170 | + | assert.equal(label.textContent, 'checks running'); | |||
| 171 | + | extra.summary = { title: 'checks passed', textContent: '' }; callback(); | |||
| 172 | + | assert.equal(label.textContent, 'checks passed'); | |||
| 173 | + | extra.summary = summary; callback(); | |||
| 166 | const before = writes; callback(); callback(); | 174 | const before = writes; callback(); callback(); | ||
| 167 | assert.equal(writes, before, 'an unchanged label is not written'); | 175 | assert.equal(writes, before, 'an unchanged label is not written'); | ||
| 168 | summary.title = ''; callback(); | 176 | summary.title = ''; callback(); | ||
M internal/adapters/web/mr.templ +4 -4
| @@ -462,7 +462,7 @@ templ mrBar(p MRPage) { | |||
| 462 | 462 | <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge"> | |
| 463 | 463 | <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span> | |
| 464 | 464 | @diffViewMenu(p.DiffView) | |
| 465 | - | @mrActions(p) | |
| 465 | + | @mrActions(p, false) | |
| 466 | 466 | </div> | |
| 467 | 467 | } | |
| 468 | 468 | ||
| @@ -781,7 +781,7 @@ templ MRMergeChecks(c ChecksSummary) { | |||
| 781 | 781 | // MRStateFragment updates only state and actions, leaving review drafts alone. | |
| 782 | 782 | templ MRStateFragment(p MRPage) { | |
| 783 | 783 | @mrState(p) | |
| 784 | - | @mrActions(p) | |
| 784 | + | @mrActions(p, true) | |
| 785 | 785 | } | |
| 786 | 786 | ||
| 787 | 787 | templ mrState(p MRPage) { | |
| @@ -793,14 +793,14 @@ templ mrState(p MRPage) { | |||
| 793 | 793 | </dd> | |
| 794 | 794 | } | |
| 795 | 795 | ||
| 796 | - | templ mrActions(p MRPage) { | |
| 796 | + | templ mrActions(p MRPage, fragment bool) { | |
| 797 | 797 | <div id="mr-actions" hx-swap-oob="true" class="mr-acts"> | |
| 798 | 798 | @MRChecksSum(p.ChecksSum) | |
| 799 | 799 | if p.Box != nil { | |
| 800 | 800 | @mergeMenu(p) | |
| 801 | 801 | } | |
| 802 | 802 | @moreMenu(p) | |
| 803 | - | if p.MergeError != "" { | |
| 803 | + | if fragment && p.MergeError != "" { | |
| 804 | 804 | <p class="d-err" role="alert">{ p.MergeError }</p> | |
| 805 | 805 | } | |
| 806 | 806 | </div> | |
| @@ -462,7 +462,7 @@ templ mrBar(p MRPage) { | |||||
| 462 | <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge"> | 462 | <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge"> | ||
| 463 | <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span> | 463 | <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span> | ||
| 464 | @diffViewMenu(p.DiffView) | 464 | @diffViewMenu(p.DiffView) | ||
| 465 | - | @mrActions(p) | 465 | + | @mrActions(p, false) |
| 466 | </div> | 466 | </div> | ||
| 467 | } | 467 | } | ||
| 468 | 468 | ||||
| @@ -781,7 +781,7 @@ templ MRMergeChecks(c ChecksSummary) { | |||||
| 781 | // MRStateFragment updates only state and actions, leaving review drafts alone. | 781 | // MRStateFragment updates only state and actions, leaving review drafts alone. | ||
| 782 | templ MRStateFragment(p MRPage) { | 782 | templ MRStateFragment(p MRPage) { | ||
| 783 | @mrState(p) | 783 | @mrState(p) | ||
| 784 | - | @mrActions(p) | 784 | + | @mrActions(p, true) |
| 785 | } | 785 | } | ||
| 786 | 786 | ||||
| 787 | templ mrState(p MRPage) { | 787 | templ mrState(p MRPage) { | ||
| @@ -793,14 +793,14 @@ templ mrState(p MRPage) { | |||||
| 793 | </dd> | 793 | </dd> | ||
| 794 | } | 794 | } | ||
| 795 | 795 | ||||
| 796 | - | templ mrActions(p MRPage) { | 796 | + | templ mrActions(p MRPage, fragment bool) { |
| 797 | <div id="mr-actions" hx-swap-oob="true" class="mr-acts"> | 797 | <div id="mr-actions" hx-swap-oob="true" class="mr-acts"> | ||
| 798 | @MRChecksSum(p.ChecksSum) | 798 | @MRChecksSum(p.ChecksSum) | ||
| 799 | if p.Box != nil { | 799 | if p.Box != nil { | ||
| 800 | @mergeMenu(p) | 800 | @mergeMenu(p) | ||
| 801 | } | 801 | } | ||
| 802 | @moreMenu(p) | 802 | @moreMenu(p) | ||
| 803 | - | if p.MergeError != "" { | 803 | + | if fragment && p.MergeError != "" { |
| 804 | <p class="d-err" role="alert">{ p.MergeError }</p> | 804 | <p class="d-err" role="alert">{ p.MergeError }</p> | ||
| 805 | } | 805 | } | ||
| 806 | </div> | 806 | </div> | ||
M internal/adapters/web/mr_async_test.go +39 -0
| @@ -125,3 +125,42 @@ func TestAsyncMergeOtherFailures(t *testing.T) { | |||
| 125 | 125 | }) | |
| 126 | 126 | } | |
| 127 | 127 | } | |
| 128 | + | ||
| 129 | + | func TestMergeGateRefusalFragment(t *testing.T) { | |
| 130 | + | s, _ := newSessionServer(t, newSessions(t, "tok"), testPublicURL) | |
| 131 | + | for _, origin := range []string{testPublicURL, "https://other.example"} { | |
| 132 | + | for _, hx := range []bool{false, true} { | |
| 133 | + | r := httptest.NewRequest(http.MethodPost, "/demo/merge-requests/3/merge", strings.NewReader("mode=squash")) | |
| 134 | + | r.Header.Set("Origin", origin) | |
| 135 | + | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 136 | + | if hx { | |
| 137 | + | r.Header.Set("HX-Request", "true") | |
| 138 | + | } | |
| 139 | + | w := httptest.NewRecorder() | |
| 140 | + | s.ServeHTTP(w, r) | |
| 141 | + | assert.Equal(t, http.StatusForbidden, w.Code) | |
| 142 | + | if hx { | |
| 143 | + | assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`) | |
| 144 | + | assert.Contains(t, w.Body.String(), `id="mr-actions"`) | |
| 145 | + | if origin == testPublicURL { | |
| 146 | + | assert.Contains(t, w.Body.String(), "your session has expired") | |
| 147 | + | } else { | |
| 148 | + | assert.Contains(t, w.Body.String(), "origin was not accepted") | |
| 149 | + | } | |
| 150 | + | } else { | |
| 151 | + | assert.NotContains(t, w.Body.String(), "hx-swap-oob") | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestNonHXMergeRefusalHasOneAlert(t *testing.T) { | |
| 158 | + | s, svc := asyncMergeServer(t, domain.MRStateMerged) | |
| 159 | + | svc.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil) | |
| 160 | + | svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict).Once() | |
| 161 | + | r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash") | |
| 162 | + | w := httptest.NewRecorder() | |
| 163 | + | s.mrAction(s.mrMerge)(w, r) | |
| 164 | + | assert.Equal(t, http.StatusConflict, w.Code) | |
| 165 | + | assert.Equal(t, 1, strings.Count(w.Body.String(), `role="alert"`)) | |
| 166 | + | } | |
| @@ -125,3 +125,42 @@ func TestAsyncMergeOtherFailures(t *testing.T) { | |||||
| 125 | }) | 125 | }) | ||
| 126 | } | 126 | } | ||
| 127 | } | 127 | } | ||
| 128 | + | ||||
| 129 | + | func TestMergeGateRefusalFragment(t *testing.T) { | |||
| 130 | + | s, _ := newSessionServer(t, newSessions(t, "tok"), testPublicURL) | |||
| 131 | + | for _, origin := range []string{testPublicURL, "https://other.example"} { | |||
| 132 | + | for _, hx := range []bool{false, true} { | |||
| 133 | + | r := httptest.NewRequest(http.MethodPost, "/demo/merge-requests/3/merge", strings.NewReader("mode=squash")) | |||
| 134 | + | r.Header.Set("Origin", origin) | |||
| 135 | + | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |||
| 136 | + | if hx { | |||
| 137 | + | r.Header.Set("HX-Request", "true") | |||
| 138 | + | } | |||
| 139 | + | w := httptest.NewRecorder() | |||
| 140 | + | s.ServeHTTP(w, r) | |||
| 141 | + | assert.Equal(t, http.StatusForbidden, w.Code) | |||
| 142 | + | if hx { | |||
| 143 | + | assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`) | |||
| 144 | + | assert.Contains(t, w.Body.String(), `id="mr-actions"`) | |||
| 145 | + | if origin == testPublicURL { | |||
| 146 | + | assert.Contains(t, w.Body.String(), "your session has expired") | |||
| 147 | + | } else { | |||
| 148 | + | assert.Contains(t, w.Body.String(), "origin was not accepted") | |||
| 149 | + | } | |||
| 150 | + | } else { | |||
| 151 | + | assert.NotContains(t, w.Body.String(), "hx-swap-oob") | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestNonHXMergeRefusalHasOneAlert(t *testing.T) { | |||
| 158 | + | s, svc := asyncMergeServer(t, domain.MRStateMerged) | |||
| 159 | + | svc.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil) | |||
| 160 | + | svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict).Once() | |||
| 161 | + | r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash") | |||
| 162 | + | w := httptest.NewRecorder() | |||
| 163 | + | s.mrAction(s.mrMerge)(w, r) | |||
| 164 | + | assert.Equal(t, http.StatusConflict, w.Code) | |||
| 165 | + | assert.Equal(t, 1, strings.Count(w.Body.String(), `role="alert"`)) | |||
| 166 | + | } | |||
M internal/adapters/web/mr_templ.go +4 -4
| @@ -2367,7 +2367,7 @@ func mrBar(p MRPage) templ.Component { | |||
| 2367 | 2367 | if templ_7745c5c3_Err != nil { | |
| 2368 | 2368 | return templ_7745c5c3_Err | |
| 2369 | 2369 | } | |
| 2370 | - | templ_7745c5c3_Err = mrActions(p).Render(ctx, templ_7745c5c3_Buffer) | |
| 2370 | + | templ_7745c5c3_Err = mrActions(p, false).Render(ctx, templ_7745c5c3_Buffer) | |
| 2371 | 2371 | if templ_7745c5c3_Err != nil { | |
| 2372 | 2372 | return templ_7745c5c3_Err | |
| 2373 | 2373 | } | |
| @@ -3994,7 +3994,7 @@ func MRStateFragment(p MRPage) templ.Component { | |||
| 3994 | 3994 | if templ_7745c5c3_Err != nil { | |
| 3995 | 3995 | return templ_7745c5c3_Err | |
| 3996 | 3996 | } | |
| 3997 | - | templ_7745c5c3_Err = mrActions(p).Render(ctx, templ_7745c5c3_Buffer) | |
| 3997 | + | templ_7745c5c3_Err = mrActions(p, true).Render(ctx, templ_7745c5c3_Buffer) | |
| 3998 | 3998 | if templ_7745c5c3_Err != nil { | |
| 3999 | 3999 | return templ_7745c5c3_Err | |
| 4000 | 4000 | } | |
| @@ -4111,7 +4111,7 @@ func mrState(p MRPage) templ.Component { | |||
| 4111 | 4111 | }) | |
| 4112 | 4112 | } | |
| 4113 | 4113 | ||
| 4114 | - | func mrActions(p MRPage) templ.Component { | |
| 4114 | + | func mrActions(p MRPage, fragment bool) templ.Component { | |
| 4115 | 4115 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 4116 | 4116 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 4117 | 4117 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| @@ -4150,7 +4150,7 @@ func mrActions(p MRPage) templ.Component { | |||
| 4150 | 4150 | if templ_7745c5c3_Err != nil { | |
| 4151 | 4151 | return templ_7745c5c3_Err | |
| 4152 | 4152 | } | |
| 4153 | - | if p.MergeError != "" { | |
| 4153 | + | if fragment && p.MergeError != "" { | |
| 4154 | 4154 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 382, "<p class=\"d-err\" role=\"alert\">") | |
| 4155 | 4155 | if templ_7745c5c3_Err != nil { | |
| 4156 | 4156 | return templ_7745c5c3_Err | |
| @@ -2367,7 +2367,7 @@ func mrBar(p MRPage) templ.Component { | |||||
| 2367 | if templ_7745c5c3_Err != nil { | 2367 | if templ_7745c5c3_Err != nil { | ||
| 2368 | return templ_7745c5c3_Err | 2368 | return templ_7745c5c3_Err | ||
| 2369 | } | 2369 | } | ||
| 2370 | - | templ_7745c5c3_Err = mrActions(p).Render(ctx, templ_7745c5c3_Buffer) | 2370 | + | templ_7745c5c3_Err = mrActions(p, false).Render(ctx, templ_7745c5c3_Buffer) |
| 2371 | if templ_7745c5c3_Err != nil { | 2371 | if templ_7745c5c3_Err != nil { | ||
| 2372 | return templ_7745c5c3_Err | 2372 | return templ_7745c5c3_Err | ||
| 2373 | } | 2373 | } | ||
| @@ -3994,7 +3994,7 @@ func MRStateFragment(p MRPage) templ.Component { | |||||
| 3994 | if templ_7745c5c3_Err != nil { | 3994 | if templ_7745c5c3_Err != nil { | ||
| 3995 | return templ_7745c5c3_Err | 3995 | return templ_7745c5c3_Err | ||
| 3996 | } | 3996 | } | ||
| 3997 | - | templ_7745c5c3_Err = mrActions(p).Render(ctx, templ_7745c5c3_Buffer) | 3997 | + | templ_7745c5c3_Err = mrActions(p, true).Render(ctx, templ_7745c5c3_Buffer) |
| 3998 | if templ_7745c5c3_Err != nil { | 3998 | if templ_7745c5c3_Err != nil { | ||
| 3999 | return templ_7745c5c3_Err | 3999 | return templ_7745c5c3_Err | ||
| 4000 | } | 4000 | } | ||
| @@ -4111,7 +4111,7 @@ func mrState(p MRPage) templ.Component { | |||||
| 4111 | }) | 4111 | }) | ||
| 4112 | } | 4112 | } | ||
| 4113 | 4113 | ||||
| 4114 | - | func mrActions(p MRPage) templ.Component { | 4114 | + | func mrActions(p MRPage, fragment bool) templ.Component { |
| 4115 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | 4115 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | ||
| 4116 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | 4116 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | ||
| 4117 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | 4117 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | ||
| @@ -4150,7 +4150,7 @@ func mrActions(p MRPage) templ.Component { | |||||
| 4150 | if templ_7745c5c3_Err != nil { | 4150 | if templ_7745c5c3_Err != nil { | ||
| 4151 | return templ_7745c5c3_Err | 4151 | return templ_7745c5c3_Err | ||
| 4152 | } | 4152 | } | ||
| 4153 | - | if p.MergeError != "" { | 4153 | + | if fragment && p.MergeError != "" { |
| 4154 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 382, "<p class=\"d-err\" role=\"alert\">") | 4154 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 382, "<p class=\"d-err\" role=\"alert\">") | ||
| 4155 | if templ_7745c5c3_Err != nil { | 4155 | if templ_7745c5c3_Err != nil { | ||
| 4156 | return templ_7745c5c3_Err | 4156 | return templ_7745c5c3_Err | ||
M internal/adapters/web/session.go +12 -2
| @@ -178,11 +178,11 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool { | |||
| 178 | 178 | // Host header, which the client chooses. A missing header, "null" and any | |
| 179 | 179 | // other origin are refused. | |
| 180 | 180 | if r.Header.Get("Origin") != s.origin { | |
| 181 | - | denyForbidden(w) | |
| 181 | + | s.denyMergeGate(w, r, "the request origin was not accepted. reload this page and try again.") | |
| 182 | 182 | return false | |
| 183 | 183 | } | |
| 184 | 184 | if !SignedIn(r.Context()) && !s.anonymousPost(r) { | |
| 185 | - | denyForbidden(w) | |
| 185 | + | s.denyMergeGate(w, r, "your session has expired. sign in again and reload.") | |
| 186 | 186 | return false | |
| 187 | 187 | } | |
| 188 | 188 | return true | |
| @@ -335,3 +335,13 @@ func (s *Server) loginRejectedPage() templ.Component { | |||
| 335 | 335 | return s.stateOnly("sign in", "404", "login link not valid", | |
| 336 | 336 | "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.") | |
| 337 | 337 | } | |
| 338 | + | ||
| 339 | + | // denyMergeGate keeps gate refusals visible to the enhanced merge form. | |
| 340 | + | func (s *Server) denyMergeGate(w http.ResponseWriter, r *http.Request, message string) { | |
| 341 | + | if !isHXMerge(r) { | |
| 342 | + | denyForbidden(w) | |
| 343 | + | return | |
| 344 | + | } | |
| 345 | + | w.Header().Set("Cache-Control", "no-store") | |
| 346 | + | s.write(r.Context(), w, r, http.StatusForbidden, MRActionStatus(message, strings.TrimSuffix(r.URL.Path, "/merge"))) | |
| 347 | + | } | |
| @@ -178,11 +178,11 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool { | |||||
| 178 | // Host header, which the client chooses. A missing header, "null" and any | 178 | // Host header, which the client chooses. A missing header, "null" and any | ||
| 179 | // other origin are refused. | 179 | // other origin are refused. | ||
| 180 | if r.Header.Get("Origin") != s.origin { | 180 | if r.Header.Get("Origin") != s.origin { | ||
| 181 | - | denyForbidden(w) | 181 | + | s.denyMergeGate(w, r, "the request origin was not accepted. reload this page and try again.") |
| 182 | return false | 182 | return false | ||
| 183 | } | 183 | } | ||
| 184 | if !SignedIn(r.Context()) && !s.anonymousPost(r) { | 184 | if !SignedIn(r.Context()) && !s.anonymousPost(r) { | ||
| 185 | - | denyForbidden(w) | 185 | + | s.denyMergeGate(w, r, "your session has expired. sign in again and reload.") |
| 186 | return false | 186 | return false | ||
| 187 | } | 187 | } | ||
| 188 | return true | 188 | return true | ||
| @@ -335,3 +335,13 @@ func (s *Server) loginRejectedPage() templ.Component { | |||||
| 335 | return s.stateOnly("sign in", "404", "login link not valid", | 335 | return s.stateOnly("sign in", "404", "login link not valid", | ||
| 336 | "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.") | 336 | "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.") | ||
| 337 | } | 337 | } | ||
| 338 | + | ||||
| 339 | + | // denyMergeGate keeps gate refusals visible to the enhanced merge form. | |||
| 340 | + | func (s *Server) denyMergeGate(w http.ResponseWriter, r *http.Request, message string) { | |||
| 341 | + | if !isHXMerge(r) { | |||
| 342 | + | denyForbidden(w) | |||
| 343 | + | return | |||
| 344 | + | } | |||
| 345 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 346 | + | s.write(r.Context(), w, r, http.StatusForbidden, MRActionStatus(message, strings.TrimSuffix(r.URL.Path, "/merge"))) | |||
| 347 | + | } | |||