2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-10-03 17:21:20 +08:00

Merge branch 'main' into feature/bump-wiki-docs-topics-op-edit

This commit is contained in:
Martin Brennan 2025-10-03 10:21:53 +10:00
commit 862284ca08
No known key found for this signature in database
GPG key ID: BD981EFEEC8F5675
76 changed files with 663 additions and 780 deletions

View file

@ -312,7 +312,7 @@ GEM
mustache (1.1.1)
net-http (0.6.0)
uri
net-imap (0.5.10)
net-imap (0.5.11)
date
net-protocol
net-pop (0.1.2)
@ -1041,7 +1041,7 @@ CHECKSUMS
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
mustache (1.1.1) sha256=90891fdd50b53919ca334c8c1031eada1215e78d226d5795e523d6123a2717d0
net-http (0.6.0) sha256=9621b20c137898af9d890556848c93603716cab516dc2c89b01a38b894e259fb
net-imap (0.5.10) sha256=f84d206a296bff48a3a10507567fc38b050d2a40c92ea0d448164f64e60d6205
net-imap (0.5.11) sha256=761143adaf153208c49e8e9d52e1b18ce17a24dc4f7f4f367480b5b98010f221
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736

View file

@ -9,7 +9,7 @@ const AdminSearchFilters = <template>
<span class={{concat "admin-search__filter --" type}}>
<DButton
class={{concatClass
"btn-small admin-search__filter-item"
"btn-default btn-small admin-search__filter-item"
(if (get @typeFilters type) "is-active")
}}
@translatedLabel={{i18n

View file

@ -32,16 +32,16 @@ export default class ColorInput extends Component {
}

@computed("hexValueWithFallback")
get normalizedValue() {
return this.normalize(this.hexValueWithFallback);
get valueForPicker() {
return this.normalize(this.hexValueWithFallback, { forPicker: true });
}

normalize(color) {
normalize(color, { forPicker = false } = {}) {
if (this._valid(color)) {
if (!color.startsWith("#")) {
color = "#" + color;
}
if (color.length === 4) {
if (color.length === 4 && (!this.skipNormalize || forPicker)) {
color =
"#" +
color
@ -109,8 +109,8 @@ export default class ColorInput extends Component {
<input
class="picker"
type="color"
value={{this.normalizedValue}}
title={{this.normalizedValue}}
value={{this.valueForPicker}}
title={{this.valueForPicker}}
{{on "input" this.onPickerInput}}
aria-labelledby={{this.ariaLabelledby}}
/>

View file

@ -39,12 +39,12 @@ export default class InlineEditCheckbox extends Component {
<DButton
@action={{fn @action this.value}}
@icon="check"
class="btn-primary btn-small submit-edit"
class="btn-success btn-small submit-edit"
/>
<DButton
@action={{this.reset}}
@icon="xmark"
class="btn-small cancel-edit"
class="btn-danger btn-small cancel-edit"
/>
{{/if}}
</div>

View file

@ -347,7 +347,7 @@ export default class InstallThemeModal extends Component {
<span>{{i18n "admin.customize.theme.installed"}}</span>
{{else}}
<DButton
class="btn-small"
class="btn-default btn-small"
@label="admin.customize.theme.install"
@disabled={{this.installDisabled}}
@icon="upload"

View file

@ -18,6 +18,7 @@ export default class SchemaSettingTypeTags extends SchemaSettingTypeModels {
@tags={{this.value}}
@onChange={{this.onInput}}
@options={{this.tagChooserOption}}
@everyTag={{@spec.every_tag}}
class={{if this.validationErrorMessage "--invalid"}}
/>


View file

@ -82,7 +82,6 @@ export default class SimpleList extends Component {
<div class="simple-list value-list" ...attributes>
{{#if this.collection}}
<div class="values">
{{this.collection.length}}
{{#each this.collection as |value index|}}
<div data-index={{index}} class="value">
<DButton

View file

@ -53,7 +53,7 @@ export default RouteTemplate(
<DButton
@action={{@controller.cancelEditingName}}
@icon="xmark"
class="btn-small cancel-edit"
class="btn-default btn-small cancel-edit"
/>
</div>
{{else}}

View file

@ -3,7 +3,6 @@ import RouteTemplate from "ember-route-template";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import DropdownMenu from "discourse/components/dropdown-menu";
import FlatButton from "discourse/components/flat-button";
import TextField from "discourse/components/text-field";
import categoryLink from "discourse/helpers/category-link";
import concatClass from "discourse/helpers/concat-class";
@ -51,10 +50,11 @@ export default RouteTemplate(
}}
>
<td class="d-table__cell --overview">
<FlatButton
<DButton
@title="admin.permalink.copy_to_clipboard"
@icon="far-clipboard"
@action={{fn @controller.copyUrl pl}}
class="btn-flat"
/>
<span
id="admin-permalink-{{pl.id}}"

View file

@ -1,7 +1,6 @@
/* eslint-disable ember/no-classic-components */
import Component from "@ember/component";
import { concat } from "@ember/helper";
import { computed } from "@ember/object";
import { compare } from "@ember/utils";
import { attributeBindings } from "@ember-decorators/component";
import DButton from "discourse/components/d-button";
@ -14,9 +13,10 @@ export default class AnonymousTopicFooterButtons extends Component {
elementId = "topic-footer-buttons";
role = "region";

@getTopicFooterButtons() allButtons;
get allButtons() {
return getTopicFooterButtons(this);
}

@computed("allButtons.[]")
get buttons() {
return (
this.allButtons

View file

@ -11,7 +11,6 @@ import { and } from "truth-helpers";
import BookmarkActionsDropdown from "discourse/components/bookmark-actions-dropdown";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import FlatButton from "discourse/components/flat-button";
import LoadMore from "discourse/components/load-more";
import BookmarkModal from "discourse/components/modal/bookmark";
import PluginOutlet from "discourse/components/plugin-outlet";
@ -214,9 +213,9 @@ export default class BookmarkList extends Component {
<PluginOutlet @name="bookmark-list-table-header">
{{#if this.bulkSelectEnabled}}
<th class="bulk-select topic-list-data">
<FlatButton
<DButton
@action={{this.toggleBulkSelect}}
@class="bulk-select"
class="bulk-select btn-flat"
@icon="list-check"
@title="bookmarks.bulk.toggle"
/>
@ -252,9 +251,9 @@ export default class BookmarkList extends Component {
/>
</span>
{{else}}
<FlatButton
<DButton
@action={{this.toggleBulkSelect}}
@class="bulk-select"
class="btn-flat bulk-select"
@icon="list-check"
@title="bookmarks.bulk.toggle"
/>

View file

@ -32,8 +32,6 @@ export default class DButton extends Component {
get btnType() {
if (this.args.icon) {
return this.computedLabel ? "btn-icon-text" : "btn-icon";
} else if (this.computedLabel) {
return "btn-text";
}
}


View file

@ -3,7 +3,7 @@ import DButton from "discourse/components/d-button";

export const DPageActionButton = <template>
<DButton
class="d-page-action-button btn-small"
class="d-page-action-button btn-small btn-default"
...attributes
@action={{@action}}
@route={{@route}}

View file

@ -129,6 +129,10 @@ export default class EditCategoryGeneral extends Component {
updateColor(field, newColor) {
const color = newColor.replace("#", "");

if (color === field.value) {
return;
}

if (field.name === "color") {
const whiteDiff = this.colorDifference(color, CATEGORY_TEXT_COLORS[0]);
const blackDiff = this.colorDifference(color, CATEGORY_TEXT_COLORS[1]);
@ -160,6 +164,41 @@ export default class EditCategoryGeneral extends Component {
return rDiff + gDiff + bDiff;
}

@action
validateColor(name, color, { addError }) {
color = color.trim();

let title;
if (name === "color") {
title = i18n("category.background_color");
} else if (name === "text_color") {
title = i18n("category.foreground_color");
} else {
throw new Error(`unknown title for category attribute ${name}`);
}

if (!color) {
addError(name, {
title,
message: i18n("category.color_validations.cant_be_empty"),
});
}

if (color.length !== 3 && color.length !== 6) {
addError(name, {
title,
message: i18n("category.color_validations.incorrect_length"),
});
}

if (!/^[0-9A-Fa-f]+$/.test(color)) {
addError(name, {
title,
message: i18n("category.color_validations.non_hexdecimal"),
});
}
}

get categoryDescription() {
if (this.args.category.description) {
return htmlSafe(this.args.category.description);
@ -318,6 +357,8 @@ export default class EditCategoryGeneral extends Component {
@name="color"
@title={{i18n "category.background_color"}}
@format="full"
@validate={{this.validateColor}}
@validation="required"
as |field|
>
<field.Custom>
@ -328,6 +369,7 @@ export default class EditCategoryGeneral extends Component {
@valid={{@category.colorValid}}
@ariaLabelledby="background-color-label"
@onChangeColor={{fn this.updateColor field}}
@skipNormalize={{true}}
/>
<ColorPicker
@colors={{this.backgroundColors}}
@ -345,6 +387,8 @@ export default class EditCategoryGeneral extends Component {
@name="text_color"
@title={{i18n "category.foreground_color"}}
@format="full"
@validate={{this.validateColor}}
@validation="required"
as |field|
>
<field.Custom>
@ -353,7 +397,8 @@ export default class EditCategoryGeneral extends Component {
<ColorInput
@hexValue={{readonly field.value}}
@ariaLabelledby="foreground-color-label"
@onBlur={{fn this.updateColor field}}
@onChangeColor={{fn this.updateColor field}}
@skipNormalize={{true}}
/>
<ColorPicker
@colors={{CATEGORY_TEXT_COLORS}}

View file

@ -1,38 +0,0 @@
/* eslint-disable ember/no-classic-components */
import Component from "@ember/component";
import {
attributeBindings,
classNames,
tagName,
} from "@ember-decorators/component";
import icon from "discourse/helpers/d-icon";
import discourseComputed from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";

@tagName("button")
@classNames("btn-flat")
@attributeBindings("disabled", "resolvedTitle:title")
export default class FlatButton extends Component {
@discourseComputed("title", "translatedTitle")
resolvedTitle(title, translatedTitle) {
if (title) {
return i18n(title);
} else if (translatedTitle) {
return translatedTitle;
}
}

keyDown(event) {
if (event.key === "Enter") {
this.action?.();
return false;
}
}

click() {
this.action?.();
return false;
}

<template>{{icon this.icon}}</template>
}

View file

@ -54,14 +54,14 @@ export default class ReviewableClaimedTopic extends Component {
@icon="xmark"
@action={{this.unclaim}}
@title="review.unclaim.help"
class="btn-small unclaim"
class="btn-default btn-small unclaim"
/>
{{else}}
<DButton
@icon="user-plus"
@title="review.claim.title"
@action={{this.claim}}
class="btn-small claim"
class="btn-default btn-small claim"
/>
{{/if}}
</div>

View file

@ -425,7 +425,7 @@ export default class SearchMenu extends Component {
@label="search.in_this_topic"
@title="search.in_this_topic_tooltip"
@action={{this.clearTopicContext}}
class="btn-small search-context"
class="btn-default btn-small search-context"
/>
{{else if this.inPMInboxContext}}
<DButton
@ -433,7 +433,7 @@ export default class SearchMenu extends Component {
@label="search.in_messages"
@title="search.in_messages_tooltip"
@action={{this.clearPMInboxContext}}
class="btn-small search-context"
class="btn-default btn-small search-context"
/>
{{/if}}


View file

@ -36,20 +36,24 @@ export default class TopicFooterButtons extends Component {
elementId = "topic-footer-buttons";
role = "region";

@getTopicFooterButtons() inlineButtons;
@getTopicFooterDropdowns() inlineDropdowns;

@alias("currentUser.can_send_private_messages") canSendPms;
@alias("topic.details.can_invite_to") canInviteTo;
@alias("currentUser.user_option.enable_defer") canDefer;
@or("topic.archived", "topic.closed", "topic.deleted") inviteDisabled;

get inlineButtons() {
return getTopicFooterButtons(this);
}

get inlineDropdowns() {
return getTopicFooterDropdowns(this);
}

@discourseComputed("canSendPms", "topic.isPrivateMessage")
canArchive(canSendPms, isPM) {
return canSendPms && isPM;
}

@computed("inlineButtons.[]", "inlineDropdowns.[]")
get inlineActionables() {
return (
this.inlineButtons
@ -70,15 +74,12 @@ export default class TopicFooterButtons extends Component {
return new TopicBookmarkManager(getOwner(this), this.topic);
}

// topic.assigned_to_user is for backward plugin support
@discourseComputed("inlineButtons.[]", "topic.assigned_to_user")
dropdownButtons(inlineButtons) {
return inlineButtons.filter((button) => button.dropdown);
get dropdownButtons() {
return this.inlineButtons.filter((button) => button.dropdown);
}

@discourseComputed("dropdownButtons.[]")
loneDropdownButton(dropdownButtons) {
return dropdownButtons.length === 1 ? dropdownButtons[0] : null;
get loneDropdownButton() {
return this.dropdownButtons.length === 1 ? this.dropdownButtons[0] : null;
}

@discourseComputed("topic.isPrivateMessage")

View file

@ -8,7 +8,7 @@ const BulkSelectCell = <template>
<button
{{on "click" @bulkSelectHelper.toggleBulkSelect}}
title={{i18n "topics.bulk.toggle"}}
class="btn-flat bulk-select"
class="btn-transparent bulk-select no-text"
>
{{icon "list-check"}}
</button>

View file

@ -88,7 +88,7 @@ export default class SortableColumn extends Component {
<button
{{on "click" @bulkSelectHelper.toggleBulkSelect}}
title={{i18n "topics.bulk.toggle"}}
class="btn-flat bulk-select"
class="btn-transparent bulk-select no-text"
>
{{icon "list-check"}}
</button>

View file

@ -45,7 +45,7 @@ export default class TopicLocalizedContentToggle extends Component {
@title={{this.title}}
class={{concatClass
"btn btn-default btn-toggle-localized-content no-text"
(unless this.showingOriginal "btn-active")
(unless this.showingOriginal "--active")
}}
@action={{this.showOriginal}}
/>

View file

@ -649,7 +649,7 @@ export default class TopicTimelineScrollArea extends Component {
@icon="layer-group"
@label="summary.short_label"
title={{i18n "summary.short_title"}}
class="show-summary btn-small"
class="show-summary btn-default btn-small"
/>
{{/if}}


View file

@ -61,14 +61,6 @@ export default class EditCategoryTabsController extends Controller {
return false;
}

if (!transientData.color) {
return false;
}

if (transientData.text_color.length < 6) {
return false;
}

if (this.saving || this.deleting) {
return true;
}
@ -120,13 +112,12 @@ export default class EditCategoryTabsController extends Controller {
}

@action
saveCategory(transientData) {
saveCategory(data) {
if (this.validators.some((validator) => validator())) {
return;
}

this.model.setProperties(transientData);

this.model.setProperties(data);
this.set("saving", true);

this.model

View file

@ -118,15 +118,23 @@ export default class SecurityController extends Controller {
"model.no_password",
"siteSettings",
"model.user_passkeys",
"model.associated_accounts"
"model.associated_accounts",
"model.can_remove_password"
)
canRemovePassword(
isAnonymous,
noPassword,
siteSettings,
userPasskeys,
associatedAccounts
associatedAccounts,
canRemove
) {
// Hint returned from staff-info controller that
// works even if staff hasn't revealed e-mails.
if (canRemove) {
return true;
}

if (
isAnonymous ||
noPassword ||

View file

@ -225,7 +225,7 @@ class FKForm extends Component {
try {
this.isSubmitting = true;

await this.validate(this.fields.values());
await this.validate([...this.fields.values()]);

if (this.formData.isValid) {
this.formData.save();

View file

@ -1,4 +1,3 @@
import { computed } from "@ember/object";
import { i18n } from "discourse-i18n";

let _topicFooterButtons = {};
@ -71,80 +70,79 @@ export function registerTopicFooterButton(button) {
_topicFooterButtons[normalizedButton.id] = normalizedButton;
}

export function getTopicFooterButtons() {
const dependentKeys = [].concat(
export function getTopicFooterButtons(context) {
const legacyDependentKeys = [].concat(
...Object.values(_topicFooterButtons)
.map((tfb) => tfb.dependentKeys)
.filter((x) => x)
);

return computed(...dependentKeys, {
get() {
const _isFunction = (descriptor) =>
descriptor && typeof descriptor === "function";
legacyDependentKeys.forEach((k) => context.get(k));

const _compute = (button, property) => {
const field = button[property];
const _isFunction = (descriptor) =>
descriptor && typeof descriptor === "function";

if (_isFunction(field)) {
return field.apply(this);
}
const _compute = (button, property) => {
const field = button[property];

return field;
};
if (_isFunction(field)) {
return field.apply(context);
}

return Object.values(_topicFooterButtons)
.filter((button) => _compute(button, "displayed"))
.map((button) => {
const discourseComputedButton = {};

discourseComputedButton.id = button.id;
discourseComputedButton.type = button.type;
return field;
};

return Object.values(_topicFooterButtons)
.filter((button) => _compute(button, "displayed"))
.map((button) => {
return {
id: button.id,
type: button.type,
get label() {
const label = _compute(button, "label");
discourseComputedButton.label = label
? i18n(label)
: _compute(button, "translatedLabel");

return label ? i18n(label) : _compute(button, "translatedLabel");
},
get ariaLabel() {
const ariaLabel = _compute(button, "ariaLabel");
if (ariaLabel) {
discourseComputedButton.ariaLabel = i18n(ariaLabel);
return i18n(ariaLabel);
} else {
const translatedAriaLabel = _compute(button, "translatedAriaLabel");
discourseComputedButton.ariaLabel =
translatedAriaLabel || discourseComputedButton.label;
return translatedAriaLabel || this.label;
}

},
get title() {
const title = _compute(button, "title");
discourseComputedButton.title = title
? i18n(title)
: _compute(button, "translatedTitle");

discourseComputedButton.classNames = (
_compute(button, "classNames") || []
).join(" ");

discourseComputedButton.icon = _compute(button, "icon");
discourseComputedButton.disabled = _compute(button, "disabled");
discourseComputedButton.dropdown = _compute(button, "dropdown");
discourseComputedButton.priority = _compute(button, "priority");

discourseComputedButton.anonymousOnly = _compute(
button,
"anonymousOnly"
);

return title ? i18n(title) : _compute(button, "translatedTitle");
},
get classNames() {
return (_compute(button, "classNames") || []).join(" ");
},
get icon() {
return _compute(button, "icon");
},
get disabled() {
return _compute(button, "disabled");
},
get dropdown() {
return _compute(button, "dropdown");
},
get priority() {
return _compute(button, "priority");
},
get anonymousOnly() {
return _compute(button, "anonymousOnly");
},
get action() {
if (_isFunction(button.action)) {
discourseComputedButton.action = () => button.action.apply(this);
return () => button.action.apply(context);
} else {
const actionName = button.action;
discourseComputedButton.action = () => this[actionName]();
return () => context[actionName]();
}

return discourseComputedButton;
});
},
});
},
};
});
}

export function clearTopicFooterButtons() {

View file

@ -1,5 +1,3 @@
import { computed } from "@ember/object";

let _topicFooterDropdowns = {};

export function registerTopicFooterDropdown(dropdown) {
@ -55,52 +53,59 @@ export function registerTopicFooterDropdown(dropdown) {
_topicFooterDropdowns[normalizedDropdown.id] = normalizedDropdown;
}

export function getTopicFooterDropdowns() {
const dependentKeys = [].concat(
export function getTopicFooterDropdowns(context) {
const legacyDependentKeys = [].concat(
...Object.values(_topicFooterDropdowns)
.map((item) => item.dependentKeys)
.filter(Boolean)
);
legacyDependentKeys.forEach((key) => context.get(key));

return computed(...dependentKeys, {
get() {
const _isFunction = (descriptor) =>
descriptor && typeof descriptor === "function";
const _isFunction = (descriptor) =>
descriptor && typeof descriptor === "function";

const _compute = (dropdown, property) => {
const field = dropdown[property];
const _compute = (dropdown, property) => {
const field = dropdown[property];

if (_isFunction(field)) {
return field.apply(this);
}
if (_isFunction(field)) {
return field.apply(context);
}

return field;
return field;
};

return Object.values(_topicFooterDropdowns)
.filter((dropdown) => _compute(dropdown, "displayed"))
.map((dropdown) => {
return {
id: dropdown.id,
type: dropdown.type,
get classNames() {
return (_compute(dropdown, "classNames") || []).join(" ");
},
get icon() {
return _compute(dropdown, "icon");
},
get disabled() {
return _compute(dropdown, "disabled");
},
get priority() {
return _compute(dropdown, "priority");
},
get content() {
return _compute(dropdown, "content");
},
get value() {
return _compute(dropdown, "value");
},
get action() {
return dropdown.action;
},
get noneItem() {
return _compute(dropdown, "noneItem");
},
};

return Object.values(_topicFooterDropdowns)
.filter((dropdown) => _compute(dropdown, "displayed"))
.map((dropdown) => {
const discourseComputedDropdown = {};

discourseComputedDropdown.id = dropdown.id;
discourseComputedDropdown.type = dropdown.type;

discourseComputedDropdown.classNames = (
_compute(dropdown, "classNames") || []
).join(" ");

discourseComputedDropdown.icon = _compute(dropdown, "icon");
discourseComputedDropdown.disabled = _compute(dropdown, "disabled");
discourseComputedDropdown.priority = _compute(dropdown, "priority");
discourseComputedDropdown.content = _compute(dropdown, "content");
discourseComputedDropdown.value = _compute(dropdown, "value");
discourseComputedDropdown.action = dropdown.action;
discourseComputedDropdown.noneItem = _compute(dropdown, "noneItem");

return discourseComputedDropdown;
});
},
});
});
}

export function clearTopicFooterDropdowns() {

View file

@ -1,4 +1,3 @@
import { fn } from "@ember/helper";
import { getOwner } from "@ember/owner";
import { htmlSafe } from "@ember/template";
import RouteTemplate from "ember-route-template";
@ -93,6 +92,7 @@ export default RouteTemplate(
<Form
@data={{@controller.formData}}
@onDirtyCheck={{@controller.isLeavingForm}}
@onSubmit={{@controller.saveCategory}}
as |form transientData|
>
<form.Section
@ -104,7 +104,7 @@ export default RouteTemplate(
<Tab
@selectedTab={{@controller.selectedTab}}
@category={{@controller.model}}
@action={{@controller.registerValidator}}
@registerValidator={{@controller.registerValidator}}
@transientData={{transientData}}
@form={{form}}
/>
@ -119,12 +119,10 @@ export default RouteTemplate(
{{/if}}

<form.Actions class="edit-category-footer">
<form.Button
<form.Submit
@disabled={{not (@controller.canSaveForm transientData)}}
@action={{fn @controller.saveCategory transientData}}
@label={{@controller.saveLabel}}
id="save-category"
class="btn-primary"
/>

{{#if @controller.model.can_delete}}

View file

@ -11,7 +11,6 @@ import UserPasskeys from "discourse/components/user-preferences/user-passkeys";
import icon from "discourse/helpers/d-icon";
import formatDate from "discourse/helpers/format-date";
import lazyHash from "discourse/helpers/lazy-hash";
import routeAction from "discourse/helpers/route-action";
import { i18n } from "discourse-i18n";

export default RouteTemplate(
@ -47,30 +46,24 @@ export default RouteTemplate(
</div>

{{#unless @controller.model.no_password}}
{{#if @controller.associatedAccountsLoaded}}
{{#if @controller.canRemovePassword}}
<div class="controls">
<a
href
{{on "click" @controller.removePassword}}
hidden={{@controller.removePasswordInProgress}}
id="remove-password-link"
>
{{icon "trash-can"}}
{{i18n "user.change_password.remove"}}
</a>
</div>
{{/if}}
{{else}}
<div class="controls">
<DButton
@action={{fn (routeAction "checkEmail") @controller.model}}
@title="admin.users.check_email.title"
@icon="envelope"
@label="admin.users.check_email.text"
/>
<div class="controls">
<button
{{on "click" @controller.removePassword}}
disabled={{not @controller.canRemovePassword}}
hidden={{@controller.removePasswordInProgress}}
class="btn btn-transparent"
id="remove-password-link"
>
{{icon "trash-can"}}
{{i18n "user.change_password.remove"}}
</button>
</div>

{{#unless @controller.canRemovePassword}}
<div class="instructions">
{{i18n "user.change_password.remove_disabled"}}
</div>
{{/if}}
{{/unless}}
{{/unless}}
</div>

@ -102,6 +95,8 @@ export default RouteTemplate(
{{/if}}
{{/if}}

<PluginOutlet @name="user-preferences-security-after-password" />

{{#if @controller.canCheckEmails}}
<div
class="control-group pref-auth-tokens"

View file

@ -294,7 +294,7 @@ export default RouteTemplate(
@label="user.invited.edit"
@action={{fn @controller.editInvite invite}}
@title="user.invited.edit"
class="btn-small edit-invite"
class="btn-default btn-small edit-invite"
/>
<DMenu
@identifier="invites-menu"

View file

@ -234,17 +234,17 @@ acceptance("User Preferences - Security", function (needs) {
await visit("/u/eviltrout/preferences/security");
// eviltrout starts with an entry in associated_accounts, can remove password
assert
.dom("#remove-password-link")
.exists("shows for user with associated account");
.dom("#remove-password-link:not(:disabled)")
.exists("is enabled for user with associated account");

updateCurrentUser({
associated_accounts: null,
});

assert
.dom("#remove-password-link")
.doesNotExist(
"does not show for user with no associated account and no passkeys"
.dom("#remove-password-link:disabled")
.exists(
"is disabled for user with no associated account and no passkeys"
);

updateCurrentUser({
@ -257,7 +257,9 @@ acceptance("User Preferences - Security", function (needs) {
},
],
});
assert.dom("#remove-password-link").exists("shows for user with passkey");
assert
.dom("#remove-password-link:not(:disabled)")
.exists("is enabled for user with passkey");
});

test("Removing User Password", async function (assert) {

View file

@ -25,13 +25,6 @@ module("Integration | Component | d-button", function (hooks) {
assert.dom("button span.d-button-label").exists("has the label");
});

test("text only button", async function (assert) {
await render(<template><DButton @label="topic.create" /></template>);

assert.dom("button.btn.btn-text").exists("has all the classes");
assert.dom("button span.d-button-label").exists("has the label");
});

test("form attribute", async function (assert) {
await render(<template><DButton @form="login-form" /></template>);

@ -40,7 +33,6 @@ module("Integration | Component | d-button", function (hooks) {

test("link-styled button", async function (assert) {
await render(<template><DButton @display="link" /></template>);

assert.dom("button.btn-link:not(.btn)").exists("has the right classes");
});


View file

@ -1,39 +0,0 @@
import { click, render, triggerKeyEvent } from "@ember/test-helpers";
import { module, test } from "qunit";
import FlatButton from "discourse/components/flat-button";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";

module("Integration | Component | flat-button", function (hooks) {
setupRenderingTest(hooks);

test("press Enter", async function (assert) {
const self = this;

this.set("foo", null);
this.set("action", () => {
this.set("foo", "bar");
});

await render(<template><FlatButton @action={{self.action}} /></template>);

await triggerKeyEvent(".btn-flat", "keydown", "Space");
assert.strictEqual(this.foo, null);

await triggerKeyEvent(".btn-flat", "keydown", "Enter");
assert.strictEqual(this.foo, "bar");
});

test("click", async function (assert) {
const self = this;

this.set("foo", null);
this.set("action", () => {
this.set("foo", "bar");
});

await render(<template><FlatButton @action={{self.action}} /></template>);

await click(".btn-flat");
assert.strictEqual(this.foo, "bar");
});
});

View file

@ -47,19 +47,6 @@ module("Integration | Component | Widget | button", function (hooks) {
assert.dom("button span.d-button-label").exists("it renders the label");
});

test("text only button", async function (assert) {
const self = this;

this.set("args", { label: "topic.create" });

await render(
<template><MountWidget @widget="button" @args={{self.args}} /></template>
);

assert.dom("button.btn.btn-text").exists("has all the classes");
assert.dom("button span.d-button-label").exists("has the label");
});

test("translatedLabel", async function (assert) {
const self = this;


View file

@ -181,10 +181,6 @@
.discourse-no-touch &:hover,
.discourse-no-touch &:focus {
background-color: var(--d-hover);

> .d-icon {
color: var(--primary-medium);
}
}
}


View file

@ -1503,7 +1503,6 @@ span.mention {
}

.post-notice {
box-sizing: border-box;
align-items: center;
background-color: var(--tertiary-very-low);
border-top: 1px solid var(--content-border-color);
@ -1514,6 +1513,7 @@ span.mention {
font-size: var(--font-down-1-rem);
border-radius: var(--d-border-radius);
gap: var(--space-2);
box-sizing: border-box;

@include viewport.until(sm) {
margin-bottom: var(--space-4);

View file

@ -28,6 +28,19 @@

.topic-navigation {
overflow-anchor: none;

.btn-toggle-localized-content.--active {
background-image: linear-gradient(
to bottom,
rgb(var(--primary-rgb), 0.6) 100%,
rgb(var(--primary-rgb), 0.6) 100%
);
color: var(--d-button-default-text-color--hover);

.d-icon {
color: var(--d-button-default-text-color--hover);
}
}
}

// timeline

View file

@ -42,8 +42,8 @@
--d-button-flat-icon-color: var(--primary-low-mid);
--d-button-flat-icon-color--hover: var(--primary);
--d-button-flat-bg-color: transparent;
--d-button-flat-bg-color--hover: transparent;
--d-button-flat-bg-color--focus: var(--primary-low);
--d-button-flat-bg-color--hover: var(--d-hover);
--d-button-flat-bg-color--focus: var(--d-hover);
--d-button-flat-text-color--disabled: var(--primary);

// .btn-flat.close
@ -68,23 +68,9 @@
$hover-bg-color: var(--d-button-default-bg-color--hover),
$hover-icon-color: var(--d-button-default-icon-color--hover)
) {
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
font-weight: normal;
color: $text-color;
background-color: $bg-color;
background-image: linear-gradient(
to bottom,
rgb(0, 0, 0, 0),
rgb(0, 0, 0, 0)
);
border-radius: var(--d-button-border-radius);
transition: var(--d-button-transition);
cursor: pointer;

@include form-item-sizing;
border: var(--d-button-border);

&:visited {
@ -142,30 +128,6 @@
}
}

.discourse-no-touch &:active:not(:hover, :focus),
.discourse-no-touch &.btn-active:not(:hover, :focus),
&:active:not(:hover, :focus),
&.btn-active:not(:hover, :focus) {
@include darken-background($bg-color, 0.6);
color: $hover-text-color;

.d-icon {
color: $hover-icon-color;
}
}

.discourse-no-touch &:active,
.discourse-no-touch &.btn-active,
&:active,
&.btn-active {
@include darken-background($bg-color, 0.3);
color: $hover-text-color;

.d-icon {
color: $hover-icon-color;
}
}

&[disabled],
&.disabled {
cursor: not-allowed;
@ -190,12 +152,10 @@
}

&.is-loading {
&.btn-text {
&.btn-small {
.loading-icon {
font-size: var(--font-down-1);
margin-right: 0.2em;
}
&.btn-small {
.loading-icon {
font-size: var(--font-down-1);
margin-right: 0.2em;
}
}

@ -207,9 +167,75 @@
}
}

/* Default button */
/* Generic btn */
.btn {
@include btn;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
font-weight: normal;
background-image: linear-gradient(
to bottom,
rgb(0, 0, 0, 0),
rgb(0, 0, 0, 0)
);
box-sizing: border-box;
padding: $vpad $hpad;
cursor: pointer;
line-height: normal;
}

/* link button */
.btn-link {
background: transparent;
border: 0;
padding: 0;
color: var(--tertiary);

@include hover {
background-color: transparent;
color: var(--tertiary-hover);

.d-icon {
color: var(--tertiary-hover);

/* For Windows High Contrast (see whcm.scss for more) */
@media (forced-colors: active) {
color: Highlight;
}
}
}

&:focus-visible {
color: var(--tertiary);
background: transparent;

@include default-focus;
}

&[disabled],
&.disabled {
color: var(--d-button-transparent-text-color);
cursor: not-allowed;

&:not(.is-loading) {
opacity: 0.4;
}

&:hover {
background: transparent;

.d-icon {
color: inherit;
}
}
}
}

/* Default button */
.btn-default {
border-radius: var(--d-button-border-radius);
}

/* Primary button */
@ -222,6 +248,7 @@
$hover-bg-color: var(--d-button-primary-bg-color--hover),
$hover-icon-color: var(--d-button-primary-icon-color--hover)
);
border-radius: var(--d-button-border-radius);
}

/* Danger button */
@ -235,6 +262,7 @@
$hover-bg-color: var(--d-button-danger-bg-color--hover),
$hover-icon-color: var(--d-button-danger-icon-color--hover)
);
border-radius: var(--d-button-border-radius);
}

/* Success button */
@ -248,6 +276,7 @@
$hover-bg-color: var(--d-button-success-bg-color--hover),
$hover-icon-color: var(--d-button-success-icon-color--hover)
);
border-radius: var(--d-button-border-radius);
}

/* Social buttons */
@ -359,28 +388,17 @@

/* Bonus Buttons */
.btn-flat {
background: var(--d-button-flat-bg-color);
transition: var(--d-button-transition);

.d-icon {
color: var(--d-button-flat-icon-color);
transition: var(--d-button-transition);
}
@include btn(
$text-color: var(--d-button-default-text-color),
$bg-color: var(--d-button-flat-bg-color),
$icon-color: var(--d-button-flat-icon-color),
$hover-text-color: var(--d-button-flat-text-color--hover),
$hover-bg-color: var(--d-button-flat-bg-color--hover),
$hover-icon-color: var(--d-button-flat-icon-color--hover)
);
border-radius: var(--d-button-border-radius);

.discourse-no-touch & {
&:hover,
&:focus-visible {
color: var(--d-button-flat-text-color--hover);

.d-icon {
color: var(--d-button-flat-icon-color--hover);
}
}

&:hover {
background: var(--d-button-flat-bg-color--hover);
}

&:focus-visible {
background: var(--d-button-flat-bg-color--focus);
}
@ -413,36 +431,6 @@
}
}

&.btn-text {
color: var(--d-button-flat-text-color);

&[disabled] {
&,
.discourse-no-touch & {
&:hover,
&.btn-hover,
&:focus-visible {
color: var(--d-button-flat-text-color--disabled);
}
}

.discourse-no-touch & {
&:not([disabled]) {
&:hover,
&.btn-hover,
&:focus-visible {
color: var(--d-button-flat-text-color--hover);
}
}

&:active,
&.btn-active {
@include darken-background(transparent, 0.2);
}
}
}
}

&:focus-visible {
outline: none;
background: var(--primary-low);
@ -457,27 +445,6 @@
}
}

.btn-link {
background: transparent;
border: 0;
padding: 0;
color: var(--tertiary);

.discourse-no-touch & {
&:hover {
color: var(--tertiary);
background: transparent;
}
}

&:focus-visible {
color: var(--tertiary);
background: transparent;

@include default-focus;
}
}

@mixin btn-colors($btn-type) {
color: var(--d-button-#{$btn-type}-bg-color);

@ -498,17 +465,18 @@

.btn-transparent {
&,
&.btn-default,
&.btn-text,
&.btn-default, // preferably NOT used in conjuction with btn-default
&.btn-icon,
&.no-text {
background: transparent;
@include btn(
$text-color: var(--d-button-transparent-text-color),
$bg-color: transparent,
$icon-color: var(--d-button-transparent-icon-color),
$hover-text-color: var(--d-button-transparent-text-color--hover),
$hover-bg-color: transparent,
$hover-icon-color: currentcolor
);
border: 0;
color: var(--d-button-transparent-text-color);

.d-icon {
color: var(--d-button-transparent-icon-color);
}

&:focus-visible {
background: transparent;
@ -518,17 +486,6 @@
color: currentcolor;
}
}

.discourse-no-touch & {
&:hover {
background: transparent;
color: var(--d-button-transparent-text-color--hover);

.d-icon {
color: currentcolor;
}
}
}
}

&.btn-primary {

View file

@ -58,7 +58,6 @@

button.bulk-select {
padding: 0;
border: 0;
margin-right: var(--space-2);
line-height: var(--line-height-large);
}

View file

@ -442,15 +442,12 @@ class TopicsController < ApplicationController
success = true

if changes.length > 0
bypass_bump = should_bypass_bump?(changes)

first_post = topic.ordered_posts.first
success =
PostRevisor.new(first_post, topic).revise!(
current_user,
changes,
validate_post: false,
bypass_bump: bypass_bump,
keep_existing_draft: params[:keep_existing_draft].to_s == "true",
)

@ -1257,11 +1254,6 @@ class TopicsController < ApplicationController
Promotion.new(current_user).review if current_user.present?
end

def should_bypass_bump?(changes)
(changes[:category_id].present? && SiteSetting.disable_category_edit_notifications) ||
(changes[:tags].present? && SiteSetting.disable_tags_edit_notifications)
end

def slugs_do_not_match
if SiteSetting.slug_generation_method != "encoded"
params[:slug] && @topic_view.topic.slug != params[:slug]

View file

@ -1529,7 +1529,8 @@ class UsersController < ApplicationController
number_of_suspensions
warnings_received_count
number_of_rejected_posts
].each { |info| result[info] = @user.public_send(info) }
can_remove_password?
].each { |info| result[info.delete_suffix("?")] = @user.public_send(info) }

render json: result
end

View file

@ -28,18 +28,6 @@ class Bookmark < ActiveRecord::Base
belongs_to :user
belongs_to :bookmarkable, polymorphic: true

has_many :reminder_notifications,
->(bookmark) do
where(notification_type: Notification.types[:bookmark_reminder]).where(
"data::jsonb->>'bookmark_id' = ?",
bookmark.id.to_s,
)
end,
class_name: "Notification",
foreign_key: :user_id,
primary_key: :user_id,
dependent: :destroy

def self.auto_delete_preferences
@auto_delete_preferences ||=
Enum.new(never: 0, when_reminder_sent: 1, on_owner_reply: 2, clear_reminder: 3)

View file

@ -104,6 +104,8 @@ class Category < ActiveRecord::Base
},
allow_nil: true
validates :slug, exclusion: { in: RESERVED_SLUGS }
validates :color, format: { with: /\A(\h{6}|\h{3})\z/ }
validates :text_color, format: { with: /\A(\h{6}|\h{3})\z/ }

after_create :create_category_definition
after_destroy :trash_category_definition

View file

@ -929,8 +929,12 @@ class User < ActiveRecord::Base
@raw_password = pw # still required to maintain compatibility with usage of password-related User interface
end

def can_remove_password?
associated_accounts.present? || passkey_credential_ids.present?
end

def remove_password
raise Discourse::InvalidAccess if associated_accounts.blank? && passkey_credential_ids.blank?
raise Discourse::InvalidAccess if !can_remove_password?

user_password.destroy if user_password
end

View file

@ -1799,6 +1799,7 @@ en:
verify_identity: "To continue, please verify your identity."
title: "Password Reset"
remove: "Remove Password"
remove_disabled: "The account needs at least one other login method (e.g. a passkey or a linked external account) before removing the password."
remove_detail: "Your account will no longer be accessible with a password unless you reset it."

second_factor_backup:
@ -4460,6 +4461,10 @@ en:
style: "Styles"
background_color: "Color"
foreground_color: "Text color"
color_validations:
cant_be_empty: "can't be empty"
incorrect_length: "must match the format #RRGGBB or #RGB"
non_hexdecimal: "must only contain hexadecimal characters (0-9, A-F)"
styles:
type: "Style"
icon: "Icon"

View file

@ -22,9 +22,6 @@ class ActionDispatch::Session::DiscourseCookieStore < ActionDispatch::Session::C
end
end
cookie_jar(request)[@key] = cookie
rescue ActionDispatch::Cookies::CookieOverflow
Rails.logger.error("Cookie overflow occurred for #{@key}: #{request.session.to_h.inspect}")
raise
end

def session_has_changed?(request, session)

View file

@ -209,7 +209,7 @@ class Auth::DefaultCurrentUserProvider
# under no conditions to suspended or inactive accounts get current_user
current_user = nil if current_user && (current_user.suspended? || !current_user.active)

if current_user && should_update_last_seen?
if current_user && !current_user.is_impersonating && should_update_last_seen?
ip = request.ip
user_id = current_user.id
old_ip = current_user.ip_address

View file

@ -587,7 +587,7 @@ class PostRevisor
end

def create_revision
modifications = post_changes.merge(@topic_changes.diff)
modifications = post_changes.merge(topic_diff)

modifications["raw"][0] = cached_original_raw || modifications["raw"][0] if modifications["raw"]

@ -608,7 +608,7 @@ class PostRevisor
def update_revision
return unless revision = PostRevision.find_by(post_id: @post.id, number: @post.version)
revision.user_id = @post.last_editor_id
modifications = post_changes.merge(@topic_changes.diff)
modifications = post_changes.merge(topic_diff)

modifications.each_key do |field|
if revision.modifications.has_key?(field)
@ -641,7 +641,7 @@ class PostRevisor
end

def topic_diff
@topic_changes.diff
@topic_changes.diff.with_indifferent_access
end

def perform_edit
@ -682,7 +682,7 @@ class PostRevisor
def only_hidden_tags_changed?
return false if (hidden_tag_names = DiscourseTagging.hidden_tag_names).blank?

modifications = post_changes.merge(@topic_changes.diff)
modifications = post_changes.merge(topic_diff)
if modifications.keys.size == 1 && (tags_diff = modifications["tags"]).present?
a, b = tags_diff[0] || [], tags_diff[1] || []
changed_tags = ((a + b) - (a & b)).map(&:presence).compact
@ -745,7 +745,7 @@ class PostRevisor

def publish_changes
options =
if !@topic_changes.diff.empty? && !@topic_changes.errored?
if !topic_diff.empty? && !@topic_changes.errored?
{ reload_topic: true }
else
{}
@ -777,6 +777,16 @@ class PostRevisor
!@topic_changes.errored?
end

def topic_category_changed?
topic_changed? && @fields.has_key?(:category_id) && topic_diff.has_key?(:category_id) &&
!@topic_changes.errored?
end

def topic_tags_changed?
topic_changed? && @fields.has_key?(:tags) && topic_diff.has_key?(:tags) &&
!@topic_changes.errored?
end

def reviewable_content_changed?
raw_changed? || topic_title_changed?
end

View file

@ -33,7 +33,7 @@
"pikaday": "1.8.2",
"playwright": "1.55.1",
"prettier": "3.5.3",
"puppeteer-core": "^24.22.3",
"puppeteer-core": "^24.23.0",
"squoosh": "https://codeload.github.com/discourse/squoosh/tar.gz/dc9649d",
"stylelint": "16.23.1",
"terser": "^5.44.0",

View file

@ -44,7 +44,7 @@ import DAutocompleteModifier, {
SKIP,
} from "discourse/modifiers/d-autocomplete";
import { i18n } from "discourse-i18n";
import Button from "discourse/plugins/chat/discourse/components/chat/composer/button";
import DButton from "discourse/plugins/chat/discourse/components/chat/composer/button";
import ChatComposerDropdown from "discourse/plugins/chat/discourse/components/chat-composer-dropdown";
import ChatComposerMessageDetails from "discourse/plugins/chat/discourse/components/chat-composer-message-details";
import ChatComposerUploads from "discourse/plugins/chat/discourse/components/chat-composer-uploads";
@ -758,7 +758,7 @@ export default class ChatComposer extends Component {

{{#if this.inlineButtons.length}}
{{#each this.inlineButtons as |button|}}
<Button
<DButton
@icon={{button.icon}}
class="-{{button.id}}"
disabled={{or this.disabled button.disabled}}
@ -780,7 +780,7 @@ export default class ChatComposer extends Component {
/>

{{#if this.site.desktopView}}
<Button
<DButton
@icon="paper-plane"
class="-send"
title={{i18n "chat.composer.send"}}
@ -794,7 +794,7 @@ export default class ChatComposer extends Component {
{{/if}}
</div>
{{#if this.site.mobileView}}
<Button
<DButton
@icon="paper-plane"
class="-send"
title={{i18n "chat.composer.send"}}

View file

@ -103,37 +103,6 @@ RSpec.describe "Bookmark message", type: :system do
)
end
end

context "with reminder notification cleanup" do
it "removes bookmark reminder notification when bookmark is deleted" do
chat_page.visit_channel(category_channel_1)
channel_page.bookmark_message(message_1)
bookmark_modal.fill_name("Check this out later")
bookmark_modal.select_preset_reminder(:tomorrow)

expect(channel_page).to have_bookmarked_message(message_1)

bookmark = Bookmark.find_by(bookmarkable: message_1, user: current_user)
Chat::MessageBookmarkable.send_reminder_notification(
bookmark,
data: {
title: bookmark.bookmarkable.chat_channel.title(current_user),
bookmarkable_url: bookmark.bookmarkable.url,
},
)

user_menu.open

expect(page).to have_css("#quick-access-all-notifications .bookmark-reminder")

channel_page.bookmark_message(message_1)
bookmark_modal.delete
bookmark_modal.confirm_delete
user_menu.open

expect(page).to have_no_css("#quick-access-all-notifications .bookmark-reminder")
end
end
end

context "when mobile", mobile: true do

View file

@ -5,6 +5,13 @@ import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import DButton from "discourse/components/d-button";
import { i18n } from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";

function isPreseeded(llm) {
if (llm.id < 0) {
return true;
}
}

class ExpandableList extends Component {
@tracked isExpanded = false;
@ -142,13 +149,24 @@ export default class AiFeatureCard extends Component {
@maxItemsToShow={{5}}
as |llm index isLastItem|
>
<LinkTo
@route="adminPlugins.show.discourse-ai-llms.edit"
@model={{llm.id}}
class="ai-feature-card__llm-link"
>
{{concat llm.name (unless (isLastItem index) ", ")}}
</LinkTo>
{{#if (isPreseeded llm)}}
<DTooltip>
<:trigger>
{{concat llm.name (unless (isLastItem index) ", ")}}
</:trigger>
<:content>
{{i18n "discourse_ai.llms.seeded_warning"}}
</:content>
</DTooltip>
{{else}}
<LinkTo
@route="adminPlugins.show.discourse-ai-llms.edit"
@model={{llm.id}}
class="ai-feature-card__llm-link"
>
{{concat llm.name (unless (isLastItem index) ", ")}}
</LinkTo>
{{/if}}
</ExpandableList>
{{else}}
<span class="ai-feature-card__label">

View file

@ -224,7 +224,7 @@ export default class AiSearchDiscoveries extends Component {
<DButton
@action={{this.continueConversation}}
@label={{this.continueConvoBtnLabel}}
class="btn-small"
class="btn-default btn-small"
>
<AiIndicatorWave @loading={{this.loadingConversationTopic}} />
</DButton>

View file

@ -120,7 +120,7 @@ export default class AiToolListEditor extends Component {
<DButton
@translatedLabel={{i18n "discourse_ai.tools.import"}}
@icon="upload"
class="btn btn-small ai-tool-list-editor__import-button"
class="btn btn-default btn-small ai-tool-list-editor__import-button"
@action={{this.importTool}}
/>
<DMenu

View file

@ -56,7 +56,7 @@ export default class AiEditSuggestionButton extends Component {
<template>
{{#unless @outletArgs.newValue}}
<DButton
class="btn-small btn-ai-suggest-edit"
class="btn-default btn-small btn-ai-suggest-edit"
@action={{this.suggest}}
@icon="discourse-sparkles"
@label="discourse_ai.ai_helper.fast_edit.suggest_button"

View file

@ -10,6 +10,10 @@
}
}

.ai-discobot-discoveries {
padding: var(--search-result-element-padding);
}

.ai-search-discoveries {
&__regular-results-title {
margin-top: 0.5em;
@ -146,7 +150,6 @@
grid-column-start: 2;
grid-row: 1 / -1;
box-sizing: border-box;
padding: var(--search-result-element-padding);
margin: 0.75em 0 0 0;
border-left: 1px solid var(--primary-low);


View file

@ -85,7 +85,6 @@ en:
composer_ai_helper_allowed_groups: "Users on these groups will see the AI helper button in the composer."
ai_helper_allowed_in_pm: "Enable the composer's AI helper in PMs."
ai_helper_model: "Model to use for the AI helper."
ai_helper_custom_prompts_allowed_groups: "Users on these groups will see the custom prompt option in the AI helper."
ai_helper_automatic_chat_thread_title_delay: "Delay in minutes before the AI helper automatically sets the chat thread title."
ai_helper_automatic_chat_thread_title: "Automatically set the chat thread titles based on thread contents."
ai_helper_illustrate_post_model: "Model to use for the composer AI helper's illustrate post feature"

View file

@ -16,7 +16,15 @@ module DiscourseAi
end

plugin.add_to_serializer(:current_user, :can_use_custom_prompts) do
scope.user.in_any_groups?(SiteSetting.ai_helper_custom_prompts_allowed_groups_map)
return [] if !SiteSetting.ai_helper_enabled

custom_prompt_allowed_group_ids =
DB.query_single(
"SELECT allowed_group_ids FROM ai_personas WHERE id = :customp_prompt_persona_id",
customp_prompt_persona_id: SiteSetting.ai_helper_custom_prompt_persona,
).flatten

scope.user.in_any_groups?(custom_prompt_allowed_group_ids)
end

plugin.on(:chat_message_created) do |message, channel, user, extra|

View file

@ -5,10 +5,15 @@ RSpec.describe "AI Composer helper", type: :system do
fab!(:non_member_group) { Fabricate(:group) }
fab!(:embedding_definition)

fab!(:custom_prompts_persona) do
Fabricate(:ai_persona, allowed_group_ids: [Group::AUTO_GROUPS[:admins]])
end

before do
enable_current_plugin
Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user)
assign_fake_provider_to(:ai_default_llm_model)
SiteSetting.ai_helper_custom_prompt_persona = custom_prompts_persona.id
SiteSetting.ai_helper_enabled = true
Jobs.run_immediately!
sign_in(user)
@ -116,7 +121,7 @@ RSpec.describe "AI Composer helper", type: :system do

context "when not a member of custom prompt group" do
let(:mode) { DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT }
before { SiteSetting.ai_helper_custom_prompts_allowed_groups = non_member_group.id.to_s }
before { custom_prompts_persona.update!(allowed_group_ids: [non_member_group.id]) }

it "does not show custom prompt option" do
trigger_composer_helper(input)

View file

@ -254,7 +254,7 @@ export default class DiscoursePostEvent extends Component {

{{#if @onClose}}
<DButton
class="btn-small discourse-post-event-close"
class="btn-default btn-small discourse-post-event-close"
@icon="xmark"
@action={{@onClose}}
/>

View file

@ -89,7 +89,7 @@ export default class EditChannel extends Component {
<DButton
@action={{@closeModal}}
@label="chat_integration.edit_channel_modal.cancel"
class="btn-large"
class="btn-default btn-large"
/>
</:footer>
</DModal>

View file

@ -232,7 +232,7 @@ export default class EditRule extends Component {
<DButton
@label="chat_integration.edit_rule_modal.cancel"
@action={{@closeModal}}
class="btn-large"
class="btn-default btn-large"
/>
</:footer>
</DModal>

View file

@ -84,7 +84,7 @@ export default class TestIntegration extends Component {
<DButton
@action={{@closeModal}}
@label="chat_integration.test_modal.close"
class="btn-large"
class="btn-default btn-large"
/>
</ConditionalLoadingSpinner>
</:footer>

View file

@ -3,7 +3,6 @@ import { on } from "@ember/modifier";
import { not } from "truth-helpers";
import DButton from "discourse/components/d-button";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import FlatButton from "discourse/components/flat-button";
import concatClass from "discourse/helpers/concat-class";
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example";

@ -14,7 +13,7 @@ const Buttons = <template>
@icon="xmark"
@translatedTitle={{bs.text}}
@disabled={{bs.disabled}}
class={{bs.class}}
class={{concatClass "btn-default" bs.class}}
/>
{{/each}}
</StyleguideExample>
@ -25,7 +24,7 @@ const Buttons = <template>
@icon="xmark"
@translatedTitle={{bs.text}}
@disabled={{bs.disabled}}
class={{bs.class}}
class={{concatClass "btn-default" bs.class}}
/>
{{/each}}
</StyleguideExample>
@ -35,7 +34,7 @@ const Buttons = <template>
<DButton
@translatedLabel={{bs.text}}
@disabled={{bs.disabled}}
class={{bs.class}}
class={{concatClass "btn-default" bs.class}}
/>
{{/each}}
</StyleguideExample>
@ -45,7 +44,7 @@ const Buttons = <template>
<DButton
@translatedLabel={{bs.text}}
@disabled={{bs.disabled}}
class={{bs.class}}
class={{concatClass "btn-default" bs.class}}
/>
{{/each}}
</StyleguideExample>
@ -58,7 +57,7 @@ const Buttons = <template>
@icon="plus"
@translatedLabel={{bs.text}}
@disabled={{bs.disabled}}
class={{bs.class}}
class={{concatClass "btn-default" bs.class}}
/>
{{/each}}
</StyleguideExample>
@ -69,7 +68,7 @@ const Buttons = <template>
@icon="plus"
@translatedLabel={{bs.text}}
@disabled={{bs.disabled}}
class={{bs.class}}
class={{concatClass "btn-default" bs.class}}
/>
{{/each}}
</StyleguideExample>
@ -123,30 +122,20 @@ const Buttons = <template>
</StyleguideExample>

<StyleguideExample @title=".btn-flat - sizes (large, default, small)">
{{#each @dummy.buttonSizes as |bs|}}
<FlatButton
@icon="trash-can"
@disabled={{bs.disabled}}
@translatedTitle={{bs.title}}
/>
{{/each}}
</StyleguideExample>

<StyleguideExample @title=".btn-flat - states">
{{#each @dummy.buttonStates as |bs|}}
<FlatButton
@icon="trash-can"
@disabled={{bs.disabled}}
@translatedTitle={{bs.title}}
/>
{{/each}}
</StyleguideExample>

<StyleguideExample
@title="<DButton> btn-flat btn-text - sizes (large, default, small)"
>
{{#each @dummy.buttonSizes as |bs|}}
<DButton
@icon="trash-can"
@disabled={{bs.disabled}}
@translatedTitle={{bs.title}}
class={{concatClass "btn-flat" bs.class}}
/>
{{/each}}
</StyleguideExample>

<StyleguideExample @title="btn-flat - states">
{{#each @dummy.buttonStates as |bs|}}
<DButton
@icon="trash-can"
@disabled={{bs.disabled}}
@translatedLabel={{bs.text}}
class={{concatClass "btn-flat" bs.class}}
@ -154,12 +143,25 @@ const Buttons = <template>
{{/each}}
</StyleguideExample>

<StyleguideExample @title="<DButton> btn-flat btn-text - states">
<StyleguideExample @title="btn-transparent - states">
{{#each @dummy.buttonStates as |bs|}}
<DButton
@icon="trash-can"
@disabled={{bs.disabled}}
@translatedLabel={{bs.text}}
class={{concatClass "btn-flat" bs.class}}
class={{concatClass "btn-transparent" bs.class}}
/>
{{/each}}
</StyleguideExample>

<StyleguideExample @title="Button link">
{{#each @dummy.buttonStates as |bs|}}
<DButton
@icon="trash-can"
@translatedLabel={{bs.text}}
@display="link"
class={{bs.class}}
@disabled={{bs.disabled}}
/>
{{/each}}
</StyleguideExample>

View file

@ -351,13 +351,13 @@ export function createData(store) {

buttonSizes: [
{ class: "btn-large", text: "large" },
{ class: "btn-default", text: "default" },
{ class: "", text: "default" },
{ class: "btn-small", text: "small" },
],

buttonStates: [
{ class: "", text: "normal" },
{ class: "btn-hover", text: "hover" },
{ class: "btn-active", text: "active" },
{ disabled: true, text: "disabled" },
],


106
pnpm-lock.yaml generated
View file

@ -116,8 +116,8 @@ importers:
specifier: 3.5.3
version: 3.5.3
puppeteer-core:
specifier: ^24.22.3
version: 24.22.3
specifier: ^24.23.0
version: 24.23.0
squoosh:
specifier: https://codeload.github.com/discourse/squoosh/tar.gz/dc9649d
version: https://codeload.github.com/discourse/squoosh/tar.gz/dc9649d
@ -1132,7 +1132,7 @@ importers:
devDependencies:
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/node@24.6.0)(jsdom@25.0.1)(terser@5.44.0)
version: 3.2.4(@types/node@24.6.1)(jsdom@25.0.1)(terser@5.44.0)

app/assets/javascripts/truth-helpers:
dependencies:
@ -2904,8 +2904,8 @@ packages:
'@types/minimatch@3.0.5':
resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}

'@types/node@24.6.0':
resolution: {integrity: sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA==}
'@types/node@24.6.1':
resolution: {integrity: sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw==}

'@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
@ -3368,8 +3368,8 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}

b4a@1.7.2:
resolution: {integrity: sha512-DyUOdz+E8R6+sruDpQNOaV0y/dBbV6X/8ZkxrDcR0Ifc3BgKlpgG0VAtfOozA0eMtJO5GGe9FsZhueLs00pTww==}
b4a@1.7.3:
resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
@ -3486,8 +3486,8 @@ packages:
bare-events@2.7.0:
resolution: {integrity: sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==}

bare-fs@4.4.4:
resolution: {integrity: sha512-Q8yxM1eLhJfuM7KXVP3zjhBvtMJCYRByoTT+wHXjpdMELv0xICFJX+1w4c7csa+WZEOsq4ItJ4RGwvzid6m/dw==}
bare-fs@4.4.5:
resolution: {integrity: sha512-TCtu93KGLu6/aiGWzMr12TmSRS6nKdfhAnzTQRbXoSWxkbb9eRd53jQ51jG7g1gYjjtto3hbBrrhzg6djcgiKg==}
engines: {bare: '>=1.16.0'}
peerDependencies:
bare-buffer: '*'
@ -4479,8 +4479,8 @@ packages:
resolution: {integrity: sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}

devtools-protocol@0.0.1495869:
resolution: {integrity: sha512-i+bkd9UYFis40RcnkW7XrOprCujXRAHg62IVh/Ah3G8MmNXpCGt1m0dTFhSdx/AVs8XEMbdOGRwdkR1Bcta8AA==}
devtools-protocol@0.0.1508733:
resolution: {integrity: sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==}

diff@7.0.0:
resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==}
@ -7623,8 +7623,8 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}

puppeteer-core@24.22.3:
resolution: {integrity: sha512-M/Jhg4PWRANSbL/C9im//Yb55wsWBS5wdp+h59iwM+EPicVQQCNs56iC5aEAO7avfDPRfxs4MM16wHjOYHNJEw==}
puppeteer-core@24.23.0:
resolution: {integrity: sha512-yl25C59gb14sOdIiSnJ08XiPP+O2RjuyZmEG+RjYmCXO7au0jcLf7fRiyii96dXGUBW7Zwei/mVKfxMx/POeFw==}
engines: {node: '>=18'}

qs@6.13.0:
@ -9055,8 +9055,8 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}

webdriver-bidi-protocol@0.2.11:
resolution: {integrity: sha512-Y9E1/oi4XMxcR8AT0ZC4OvYntl34SPgwjmELH+owjBr0korAX4jKgZULBWILGCVGdVCQ0dodTToIETozhG8zvA==}
webdriver-bidi-protocol@0.3.6:
resolution: {integrity: sha512-mlGndEOA9yK9YAbvtxaPTqdi/kaCWYYfwrZvGzcmkr/3lWM+tQj53BxtpVd6qbC6+E5OnHXgCcAhre6AkXzxjA==}

webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@ -11400,7 +11400,7 @@ snapshots:
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 24.6.0
'@types/node': 24.6.1

'@types/chai-as-promised@7.1.8':
dependencies:
@ -11414,11 +11414,11 @@ snapshots:

'@types/connect@3.4.38':
dependencies:
'@types/node': 24.6.0
'@types/node': 24.6.1

'@types/cors@2.8.18':
dependencies:
'@types/node': 24.6.0
'@types/node': 24.6.1

'@types/deep-eql@4.0.2': {}

@ -11441,7 +11441,7 @@ snapshots:

'@types/express-serve-static-core@4.19.6':
dependencies:
'@types/node': 24.6.0
'@types/node': 24.6.1
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 0.17.5
@ -11455,11 +11455,11 @@ snapshots:

'@types/fs-extra@5.1.0':
dependencies:
'@types/node': 24.6.0
'@types/node': 24.6.1

'@types/fs-extra@8.1.5':
dependencies:
'@types/node': 24.6.0
'@types/node': 24.6.1

'@types/glob@9.0.0':
dependencies:
@ -11488,7 +11488,7 @@ snapshots:

'@types/minimatch@3.0.5': {}

'@types/node@24.6.0':
'@types/node@24.6.1':
dependencies:
undici-types: 7.13.0

@ -11503,19 +11503,19 @@ snapshots:
'@types/rimraf@2.0.5':
dependencies:
'@types/glob': 9.0.0
'@types/node': 24.6.0
'@types/node': 24.6.1

'@types/rsvp@4.0.9': {}

'@types/send@0.17.5':
dependencies:
'@types/mime': 1.3.5
'@types/node': 24.6.0
'@types/node': 24.6.1

'@types/serve-static@1.15.8':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 24.6.0
'@types/node': 24.6.1
'@types/send': 0.17.5

'@types/sizzle@2.3.10': {}
@ -11534,7 +11534,7 @@ snapshots:

'@types/yauzl@2.10.3':
dependencies:
'@types/node': 24.6.0
'@types/node': 24.6.1
optional: true

'@typescript-eslint/tsconfig-utils@8.39.0(typescript@5.9.2)':
@ -11591,13 +11591,13 @@ snapshots:
chai: 5.2.0
tinyrainbow: 2.0.0

'@vitest/mocker@3.2.4(vite@7.0.0(@types/node@24.6.0)(terser@5.44.0))':
'@vitest/mocker@3.2.4(vite@7.0.0(@types/node@24.6.1)(terser@5.44.0))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.19
optionalDependencies:
vite: 7.0.0(@types/node@24.6.0)(terser@5.44.0)
vite: 7.0.0(@types/node@24.6.1)(terser@5.44.0)

'@vitest/pretty-format@3.2.4':
dependencies:
@ -12029,7 +12029,7 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0

b4a@1.7.2: {}
b4a@1.7.3: {}

babel-import-util@1.4.1: {}

@ -12163,7 +12163,7 @@ snapshots:

bare-events@2.7.0: {}

bare-fs@4.4.4:
bare-fs@4.4.5:
dependencies:
bare-events: 2.7.0
bare-path: 3.0.0
@ -12837,7 +12837,7 @@ snapshots:

chrome-launcher@1.2.1:
dependencies:
'@types/node': 24.6.0
'@types/node': 24.6.1
escape-string-regexp: 4.0.0
is-wsl: 2.2.0
lighthouse-logger: 2.0.2
@ -12854,9 +12854,9 @@ snapshots:

chrome-trace-event@1.0.4: {}

chromium-bidi@9.1.0(devtools-protocol@0.0.1495869):
chromium-bidi@9.1.0(devtools-protocol@0.0.1508733):
dependencies:
devtools-protocol: 0.0.1495869
devtools-protocol: 0.0.1508733
mitt: 3.0.1
zod: 3.25.76

@ -13275,7 +13275,7 @@ snapshots:

detect-newline@4.0.1: {}

devtools-protocol@0.0.1495869: {}
devtools-protocol@0.0.1508733: {}

diff@7.0.0: {}

@ -14014,7 +14014,7 @@ snapshots:
engine.io@6.6.4:
dependencies:
'@types/cors': 2.8.18
'@types/node': 24.6.0
'@types/node': 24.6.1
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.7.2
@ -15750,7 +15750,7 @@ snapshots:

jest-worker@27.5.1:
dependencies:
'@types/node': 24.6.0
'@types/node': 24.6.1
merge-stream: 2.0.0
supports-color: 8.1.1

@ -17256,14 +17256,14 @@ snapshots:

punycode@2.3.1: {}

puppeteer-core@24.22.3:
puppeteer-core@24.23.0:
dependencies:
'@puppeteer/browsers': 2.10.10
chromium-bidi: 9.1.0(devtools-protocol@0.0.1495869)
chromium-bidi: 9.1.0(devtools-protocol@0.0.1508733)
debug: 4.4.3(supports-color@8.1.1)
devtools-protocol: 0.0.1495869
devtools-protocol: 0.0.1508733
typed-query-selector: 2.12.0
webdriver-bidi-protocol: 0.2.11
webdriver-bidi-protocol: 0.3.6
ws: 8.18.3
transitivePeerDependencies:
- bare-buffer
@ -18351,7 +18351,7 @@ snapshots:
pump: 3.0.3
tar-stream: 3.1.7
optionalDependencies:
bare-fs: 4.4.4
bare-fs: 4.4.5
bare-path: 3.0.0
transitivePeerDependencies:
- bare-buffer
@ -18359,7 +18359,7 @@ snapshots:

tar-stream@3.1.7:
dependencies:
b4a: 1.7.2
b4a: 1.7.3
fast-fifo: 1.3.2
streamx: 2.23.0
transitivePeerDependencies:
@ -18490,7 +18490,7 @@ snapshots:

text-decoder@1.2.3:
dependencies:
b4a: 1.7.2
b4a: 1.7.3
transitivePeerDependencies:
- react-native-b4a

@ -18866,13 +18866,13 @@ snapshots:
x-is-array: 0.1.0
x-is-string: 0.1.0

vite-node@3.2.4(@types/node@24.6.0)(terser@5.44.0):
vite-node@3.2.4(@types/node@24.6.1)(terser@5.44.0):
dependencies:
cac: 6.7.14
debug: 4.4.3(supports-color@8.1.1)
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.0.0(@types/node@24.6.0)(terser@5.44.0)
vite: 7.0.0(@types/node@24.6.1)(terser@5.44.0)
transitivePeerDependencies:
- '@types/node'
- jiti
@ -18887,7 +18887,7 @@ snapshots:
- tsx
- yaml

vite@7.0.0(@types/node@24.6.0)(terser@5.44.0):
vite@7.0.0(@types/node@24.6.1)(terser@5.44.0):
dependencies:
esbuild: 0.25.10
fdir: 6.4.6(picomatch@4.0.2)
@ -18896,15 +18896,15 @@ snapshots:
rollup: 4.44.0
tinyglobby: 0.2.14
optionalDependencies:
'@types/node': 24.6.0
'@types/node': 24.6.1
fsevents: 2.3.3
terser: 5.44.0

vitest@3.2.4(@types/node@24.6.0)(jsdom@25.0.1)(terser@5.44.0):
vitest@3.2.4(@types/node@24.6.1)(jsdom@25.0.1)(terser@5.44.0):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.0.0(@types/node@24.6.0)(terser@5.44.0))
'@vitest/mocker': 3.2.4(vite@7.0.0(@types/node@24.6.1)(terser@5.44.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@ -18922,11 +18922,11 @@ snapshots:
tinyglobby: 0.2.14
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.0.0(@types/node@24.6.0)(terser@5.44.0)
vite-node: 3.2.4(@types/node@24.6.0)(terser@5.44.0)
vite: 7.0.0(@types/node@24.6.1)(terser@5.44.0)
vite-node: 3.2.4(@types/node@24.6.1)(terser@5.44.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.6.0
'@types/node': 24.6.1
jsdom: 25.0.1(supports-color@8.1.1)
transitivePeerDependencies:
- jiti
@ -19050,7 +19050,7 @@ snapshots:

web-streams-polyfill@3.3.3: {}

webdriver-bidi-protocol@0.2.11: {}
webdriver-bidi-protocol@0.3.6: {}

webidl-conversions@3.0.1: {}


View file

@ -13,24 +13,4 @@ describe ActionDispatch::Session::DiscourseCookieStore, type: :request do
expect(response.cookies["_forum_session"]).not_to be_present
expect(session[:_csrf_token]).to eq(csrf_token)
end

describe "Cookie overflow" do
context "when cookie size exceeds limit" do
let(:fake_logger) { FakeLogger.new }

before do
Rails.logger.broadcast_to(fake_logger)
allow_any_instance_of(ActionController::RequestForgeryProtection).to receive(
:generate_csrf_token,
).and_return(SecureRandom.urlsafe_base64(4097))
end

after { Rails.logger.stop_broadcasting_to(fake_logger) }

it "logs an error" do
get "/session/csrf.json"
expect(fake_logger.errors).to include(/Cookie overflow occurred.*"_csrf_token"=>/)
end
end
end
end

View file

@ -285,6 +285,19 @@ RSpec.describe Auth::DefaultCurrentUserProvider do
end
end

describe "when impersonating another user" do
it "should not update User#last_seen_at" do
old_timestamp = 1.week.ago
user.update!(last_seen_at: old_timestamp)
User.any_instance.stubs(:is_impersonating).returns(true)

provider2 = provider("/", "HTTP_COOKIE" => "_t=#{cookie}")
u = provider2.current_user

expect(u.reload.last_seen_at).to eq_time(old_timestamp)
end
end

it "should not cache an invalid user when Rails hasn't set `path_parameters` on the request yet" do
SiteSetting.login_required = true
user = Fabricate(:user)

View file

@ -1621,6 +1621,32 @@ describe PostRevisor do
}.not_to change { post.topic.bumped_at }
end

it "doesn't bump the topic when editing the topic title" do
expect {
post_revisor.revise!(
post.user,
{ title: "This is an updated topic title" },
revised_at: post.updated_at + SiteSetting.editing_grace_period + 1.seconds,
)
}.not_to change { post.topic.bumped_at }
end

it "doesn't bump the topic when editing the topic category" do
expect {
post_revisor.revise!(
post.user,
{ category_id: Fabricate(:category).id },
revised_at: post.updated_at + SiteSetting.editing_grace_period + 1.seconds,
)
}.not_to change { post.topic.bumped_at }
end

it "doesn't bump the topic when editing tags" do
expect { post_revisor.revise!(post.user, { tags: %w[totally update] }) }.not_to change {
post.topic.bumped_at
}
end

describe "should_bump_topic plugin modifier" do
let(:plugin_instance) { Plugin::Instance.new }
let(:modifier_return_value) { nil }

View file

@ -178,100 +178,4 @@ RSpec.describe Bookmark do
end
end
end

describe "reminder notifications cleanup" do
fab!(:user)

it "deletes reminder notifications when bookmark is destroyed" do
bookmark = Fabricate(:bookmark, user: user, bookmarkable: post)

notification =
Fabricate(
:bookmark_reminder_notification,
user:,
post:,
data: {
bookmark_id: bookmark.id,
bookmarkable_type: "Post",
bookmarkable_id: post.id,
title: post.topic.title,
bookmarkable_url: post.url,
}.to_json,
)

expect { bookmark.destroy }.to change { Notification.find_by(id: notification.id) }.from(
notification,
).to(nil)
end

it "does not delete other user's reminder notifications" do
other_user = Fabricate(:user)
bookmark = Fabricate(:bookmark, user: user, bookmarkable: post)

user_notification =
Fabricate(
:bookmark_reminder_notification,
user:,
post:,
data: {
bookmark_id: bookmark.id,
bookmarkable_type: "Post",
bookmarkable_id: post.id,
title: post.topic.title,
bookmarkable_url: post.url,
}.to_json,
)

other_notification =
Fabricate(
:bookmark_reminder_notification,
user: other_user,
post: post,
data: {
bookmark_id: bookmark.id,
bookmarkable_type: "Post",
bookmarkable_id: post.id,
title: post.topic.title,
bookmarkable_url: post.url,
}.to_json,
)

expect { bookmark.destroy }.to change { Notification.find_by(id: user_notification.id) }
.from(user_notification)
.to(nil)
.and(not_change { Notification.find_by(id: other_notification.id) })
end

it "does not delete non-reminder notifications" do
bookmark = Fabricate(:bookmark, user: user, bookmarkable: post)

reminder_notification =
Fabricate(
:bookmark_reminder_notification,
user:,
post:,
data: {
bookmark_id: bookmark.id,
bookmarkable_type: "Post",
bookmarkable_id: post.id,
title: post.topic.title,
bookmarkable_url: post.url,
}.to_json,
)

other_notification =
Fabricate(
:notification,
user:,
post:,
notification_type: Notification.types[:liked],
data: { post_number: post.post_number }.to_json,
)

expect { bookmark.destroy }.to change { Notification.find_by(id: reminder_notification.id) }
.from(reminder_notification)
.to(nil)
.and(not_change { Notification.find_by(id: other_notification.id) })
end
end
end

View file

@ -38,6 +38,34 @@ RSpec.describe Category do
expect(cats.errors[:name]).to be_present
end

it "validates format of color" do
expect(Fabricate.build(:category, color: "fff", user:)).to be_valid
expect(Fabricate.build(:category, color: "1ac", user:)).to be_valid
expect(Fabricate.build(:category, color: "fffeee", user:)).to be_valid
expect(Fabricate.build(:category, color: "FF11CC", user:)).to be_valid
expect(Fabricate.build(:category, color: "ABC", user:)).to be_valid

expect(Fabricate.build(:category, color: "ffq1e1", user:)).to_not be_valid
expect(Fabricate.build(:category, color: "", user:)).to_not be_valid
expect(Fabricate.build(:category, color: "f", user:)).to_not be_valid
expect(Fabricate.build(:category, color: "21", user:)).to_not be_valid
expect(Fabricate.build(:category, color: "XCA", user:)).to_not be_valid
end

it "validates format of text_color" do
expect(Fabricate.build(:category, text_color: "fff", user:)).to be_valid
expect(Fabricate.build(:category, text_color: "1ac", user:)).to be_valid
expect(Fabricate.build(:category, text_color: "fffeee", user:)).to be_valid
expect(Fabricate.build(:category, text_color: "FF11CC", user:)).to be_valid
expect(Fabricate.build(:category, text_color: "ABC", user:)).to be_valid

expect(Fabricate.build(:category, text_color: "ffq1e1", user:)).to_not be_valid
expect(Fabricate.build(:category, text_color: "", user:)).to_not be_valid
expect(Fabricate.build(:category, text_color: "f", user:)).to_not be_valid
expect(Fabricate.build(:category, text_color: "21", user:)).to_not be_valid
expect(Fabricate.build(:category, text_color: "XCA", user:)).to_not be_valid
end

describe "Associations" do
it { is_expected.to have_one(:category_setting).dependent(:destroy) }


View file

@ -1859,64 +1859,6 @@ RSpec.describe TopicsController do
expect(response.status).to eq(200)
end

context "when using SiteSetting.disable_category_edit_notifications" do
it "doesn't bump the topic if the setting is enabled" do
SiteSetting.disable_category_edit_notifications = true
last_bumped_at = topic.bumped_at
expect(last_bumped_at).not_to be_nil

expect do
put "/t/#{topic.slug}/#{topic.id}.json", params: { category_id: category.id }
end.to change { topic.reload.category_id }.to(category.id)

expect(response.status).to eq(200)
expect(topic.reload.bumped_at).to eq_time(last_bumped_at)
end

it "bumps the topic if the setting is disabled" do
SiteSetting.disable_category_edit_notifications = false
last_bumped_at = topic.bumped_at
expect(last_bumped_at).not_to be_nil

expect do
put "/t/#{topic.slug}/#{topic.id}.json", params: { category_id: category.id }
end.to change { topic.reload.category_id }.to(category.id)

expect(response.status).to eq(200)
expect(topic.reload.bumped_at).not_to eq_time(last_bumped_at)
end
end

context "when using SiteSetting.disable_tags_edit_notifications" do
fab!(:t1, :tag)
fab!(:t2, :tag)
let(:tags) { [t1, t2] }

it "doesn't bump the topic if the setting is enabled" do
SiteSetting.disable_tags_edit_notifications = true
last_bumped_at = topic.bumped_at
expect(last_bumped_at).not_to be_nil

put "/t/#{topic.slug}/#{topic.id}.json", params: { tags: tags.map(&:name) }

expect(topic.reload.tags).to match_array(tags)
expect(response.status).to eq(200)
expect(topic.reload.bumped_at).to eq_time(last_bumped_at)
end

it "bumps the topic if the setting is disabled" do
SiteSetting.disable_tags_edit_notifications = false
last_bumped_at = topic.bumped_at
expect(last_bumped_at).not_to be_nil

put "/t/#{topic.slug}/#{topic.id}.json", params: { tags: tags.map(&:name) }

expect(topic.reload.tags).to match_array(tags)
expect(response.status).to eq(200)
expect(topic.reload.bumped_at).not_to eq_time(last_bumped_at)
end
end

describe "when first post is locked" do
it "blocks user from editing even if they are in 'edit_all_topic_groups' and 'edit_all_post_groups'" do
SiteSetting.edit_all_topic_groups = Group::AUTO_GROUPS[:trust_level_3]

View file

@ -7705,14 +7705,15 @@ RSpec.describe UsersController do
expect(notifications.first["data"]["bookmark_id"]).to eq(bookmark_with_reminder.id)
end

it "doesnt show unread notifications when the bookmark has been deleted" do
it "shows unread notifications even if the bookmark has been deleted if they have bookmarkable data" do
bookmark_with_reminder.destroy!

get "/u/#{user.username}/user-menu-bookmarks"
expect(response.status).to eq(200)

notifications = response.parsed_body["notifications"]
expect(notifications.size).to eq(0)
expect(notifications.size).to eq(1)
expect(notifications.first["data"]["bookmark_id"]).to eq(bookmark_with_reminder.id)
end

it "does not show unread notifications if the bookmark has been deleted if they only have the bookmark_id data" do
@ -8022,9 +8023,10 @@ RSpec.describe UsersController do
number_of_suspensions
warnings_received_count
number_of_rejected_posts
can_remove_password?
].each do |info|
user_instance.expects(info).returns(user.public_send(info))
result[info.to_s] = user.public_send(info)
result[info.to_s.delete_suffix("?")] = user.public_send(info)
end

sign_in(admin)

View file

@ -0,0 +1,73 @@
# frozen_string_literal: true

describe "Edit Category General", type: :system do
fab!(:admin)
fab!(:category)
let(:category_page) { PageObjects::Pages::Category.new }
let(:form) { PageObjects::Components::FormKit.new(".form-kit") }
before { sign_in(admin) }

context "when changing background color" do
it "displays an error when the hex code is invalid" do
category_page.visit_general(category)

form.field("color").component.find("input.hex-input").fill_in(with: "ABZ")
category_page.save_settings
expect(form.field("color")).to have_errors(
I18n.t("js.category.color_validations.non_hexdecimal"),
)

form.field("color").component.find("input.hex-input").fill_in(with: "")
category_page.save_settings
expect(form.field("color")).to have_errors(
I18n.t("js.category.color_validations.cant_be_empty"),
)

form.field("color").component.find("input.hex-input").fill_in(with: "A")
category_page.save_settings
expect(form.field("color")).to have_errors(
I18n.t("js.category.color_validations.incorrect_length"),
)
end

it "saves successfully when the hex code is valid" do
category_page.visit_general(category)

form.field("color").component.find("input.hex-input").fill_in(with: "AB1")
category_page.save_settings
expect(form.field("color")).to have_no_errors
end
end

context "when changing text color" do
it "displays an error when the hex code is invalid" do
category_page.visit_general(category)

form.field("text_color").component.find("input.hex-input").fill_in(with: "ABZ")
category_page.save_settings
expect(form.field("text_color")).to have_errors(
I18n.t("js.category.color_validations.non_hexdecimal"),
)

form.field("text_color").component.find("input.hex-input").fill_in(with: "")
category_page.save_settings
expect(form.field("text_color")).to have_errors(
I18n.t("js.category.color_validations.cant_be_empty"),
)

form.field("text_color").component.find("input.hex-input").fill_in(with: "A")
category_page.save_settings
expect(form.field("text_color")).to have_errors(
I18n.t("js.category.color_validations.incorrect_length"),
)
end

it "saves successfully when the hex code is valid" do
category_page.visit_general(category)

form.field("text_color").component.find("input.hex-input").fill_in(with: "AB1")
category_page.save_settings
expect(form.field("text_color")).to have_no_errors
end
end
end

View file

@ -80,7 +80,7 @@ module PageObjects
end

def has_no_errors?
!has_css?(".form-kit__errors")
has_no_css?(".form-kit__errors")
end

def control_type

View file

@ -15,6 +15,11 @@ module PageObjects
self
end

def visit_general(category)
page.visit("/c/#{category.slug}/edit/general")
self
end

def visit_edit_template(category)
page.visit("/c/#{category.slug}/edit/topic-template")
self