fix(admin): allow single community mode to be turned back on (#1684)

This commit is contained in:
Hampus
2026-08-16 22:29:23 +02:00
committed by GitHub
parent 45289b5531
commit be69157811
9 changed files with 132 additions and 28 deletions
-2
View File
@@ -12543,7 +12543,6 @@
"type": "object",
"properties": {
"single_community_enabled": {"type": "boolean"},
"single_community_locked": {"type": "boolean"},
"single_community_guild_id": {"nullable": true, "type": "string"},
"direct_messages_disabled": {"type": "boolean"},
"direct_messages_locked": {"type": "boolean"},
@@ -12578,7 +12577,6 @@
},
"required": [
"single_community_enabled",
"single_community_locked",
"single_community_guild_id",
"direct_messages_disabled",
"direct_messages_locked",
@@ -24,8 +24,6 @@ pub struct InstanceConfigResponse {
pub struct InstancePolicyResponse {
#[serde(default)]
pub single_community_enabled: bool,
#[serde(default)]
pub single_community_locked: bool,
pub single_community_guild_id: Option<String>,
#[serde(default)]
pub direct_messages_disabled: bool,
@@ -45,7 +43,6 @@ impl Default for InstancePolicyResponse {
fn default() -> Self {
Self {
single_community_enabled: false,
single_community_locked: false,
single_community_guild_id: None,
direct_messages_disabled: false,
direct_messages_locked: false,
+7 -3
View File
@@ -216,7 +216,11 @@ pub async fn instance_config_post(
Err(message) => FlashData::error(message),
},
"disable_single_community" => {
let update = build_disable_single_community_update();
let update = build_single_community_update(false);
instance_config_result(client.update_instance_config(&update).await)
}
"enable_single_community" => {
let update = build_single_community_update(true);
instance_config_result(client.update_instance_config(&update).await)
}
"create_registration_url" => match build_create_registration_url_request(&form) {
@@ -696,14 +700,14 @@ fn build_smtp_test_request(form: &MultiValueForm) -> Result<InstanceEmailSmtpTes
})
}
fn build_disable_single_community_update() -> InstanceConfigUpdateRequest {
fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: None,
policy: Some(InstancePolicyUpdateRequest {
single_community_enabled: Some(false),
single_community_enabled: Some(enabled),
single_community_name: None,
direct_messages_disabled: None,
premium_mode: None,
@@ -208,18 +208,14 @@ fn single_community_form(base: &str, csrf_token: &str, policy: &InstancePolicyRe
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Single community" }
(badge(status.0, status.1))
@if policy.single_community_locked {
(badge("Locked", BadgeVariant::Warning))
}
}
@if let Some(guild_id) = policy.single_community_guild_id.as_deref() {
p class="break-all text-xs text-neutral-500" { "Community guild ID: " (guild_id) }
}
@if policy.single_community_enabled && !policy.single_community_locked {
@if policy.single_community_enabled {
p class="text-sm text-neutral-500" {
"This instance funnels every member into a single community. Disabling it is \
permanent: single-community mode can only be enabled again from the \
self-host setup wizard, never from this panel."
"This instance funnels every member into a single community. You can turn this \
off and on again from here. The community itself is kept either way."
}
form method="post" action={(base) "/instance-config?action=disable_single_community"} {
(csrf_input(csrf_token))
@@ -227,15 +223,21 @@ fn single_community_form(base: &str, csrf_token: &str, policy: &InstancePolicyRe
(danger_button("Disable single-community mode"))
}))
}
} @else if policy.single_community_enabled {
} @else if policy.single_community_guild_id.is_some() {
p class="text-sm text-neutral-500" {
"Single-community mode is enabled and locked for this instance. It cannot be \
changed from the admin panel."
"Single-community mode is off. Turning it on again reuses the community above \
if it still exists, otherwise a new one is created."
}
form method="post" action={(base) "/instance-config?action=enable_single_community"} {
(csrf_input(csrf_token))
(form_actions(html! {
(submit_button("Enable single-community mode"))
}))
}
} @else {
p class="text-sm text-neutral-500" {
"Single-community mode is off. It can only be turned on from the self-host \
setup wizard, not from this panel."
"Single-community mode is off. It can only be turned on for the first time \
from the self-host setup wizard."
}
}
}
@@ -91,7 +91,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
app_public: appPublic,
policy: {
single_community_enabled: policy.single_community_enabled,
single_community_locked: policy.single_community_locked,
single_community_guild_id: policy.single_community_guild_id,
direct_messages_disabled: policy.direct_messages_disabled,
direct_messages_locked: policy.direct_messages_locked,
@@ -551,20 +550,19 @@ async function applyInstancePolicyUpdate(
policy.single_community_enabled !== current.single_community_enabled
) {
if (policy.single_community_enabled) {
if (appPublic.setup.configured || current.single_community_locked) {
if (appPublic.setup.configured && current.single_community_guild_id == null) {
throw new InstancePolicyTransitionNotAllowedError();
}
const adminUser = await ctx.get('userRepository').findUnique(ctx.get('adminUserId'));
if (!adminUser) {
throw new InstancePolicyTransitionNotAllowedError();
}
await ctx.get('singleCommunityService').createStockCommunity({
await ctx.get('singleCommunityService').ensureStockCommunity({
owner: adminUser,
name: policy.single_community_name?.trim() || appPublic.branding.product_name,
});
} else {
patch.single_community_enabled = false;
patch.single_community_locked = true;
}
}
if (
@@ -77,7 +77,6 @@ export type InstancePremiumMode = 'mirror' | 'everyone';
export interface InstancePolicyConfig {
single_community_enabled: boolean;
single_community_locked: boolean;
single_community_guild_id: string | null;
direct_messages_disabled: boolean;
direct_messages_locked: boolean;
@@ -396,7 +395,6 @@ function normalizeAppPublicConfig(value: unknown): InstanceAppPublicConfig {
const DEFAULT_INSTANCE_POLICY_CONFIG: InstancePolicyConfig = {
single_community_enabled: false,
single_community_locked: false,
single_community_guild_id: null,
direct_messages_disabled: false,
direct_messages_locked: false,
@@ -420,7 +418,6 @@ function normalizeInstancePolicyConfig(value: unknown): InstancePolicyConfig {
}
return {
single_community_enabled: value.single_community_enabled === true,
single_community_locked: value.single_community_locked === true,
single_community_guild_id: normalizeNullableString(value.single_community_guild_id),
direct_messages_disabled: value.direct_messages_disabled === true,
direct_messages_locked: value.direct_messages_locked === true,
@@ -50,6 +50,37 @@ export class SingleCommunityService {
}
}
async ensureStockCommunity(params: {owner: User; name: string}): Promise<GuildID> {
const policy = await this.instanceConfigRepository.getInstancePolicyConfig();
const designatedGuildId = await this.findDesignatedGuild(policy.single_community_guild_id);
if (designatedGuildId != null) {
await this.instanceConfigRepository.setInstancePolicyConfig({
single_community_enabled: true,
single_community_guild_id: designatedGuildId.toString(),
});
return designatedGuildId;
}
return this.createStockCommunity(params);
}
private async findDesignatedGuild(rawGuildId: string | null): Promise<GuildID | null> {
if (!rawGuildId) {
return null;
}
let guildId: GuildID;
try {
guildId = createGuildID(BigInt(rawGuildId));
} catch {
return null;
}
try {
await this.guildDataService.getGuildSystem(guildId);
return guildId;
} catch {
return null;
}
}
async createStockCommunity(params: {owner: User; name: string}): Promise<GuildID> {
const guild = await this.guildDataService.createGuild({user: params.owner, data: {name: params.name}});
const guildId = createGuildID(BigInt(guild.id));
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import type {User} from '../../models/User';
import type {InstancePolicyConfig} from '../InstanceConfigRepository';
import {SingleCommunityService} from '../SingleCommunityService';
const EXISTING_GUILD_ID = '1234567890123456789';
const OWNER = {id: 42n} as unknown as User;
interface Harness {
service: SingleCommunityService;
written: Array<Partial<InstancePolicyConfig>>;
createdNames: Array<string>;
}
function createHarness(params: {designatedGuildId: string | null; designatedGuildExists: boolean}): Harness {
const written: Array<Partial<InstancePolicyConfig>> = [];
const createdNames: Array<string> = [];
let nextCreatedGuildId = 999n;
const instanceConfigRepository = {
getInstancePolicyConfig: async () => ({
single_community_enabled: false,
single_community_guild_id: params.designatedGuildId,
}),
setInstancePolicyConfig: async (patch: Partial<InstancePolicyConfig>) => {
written.push(patch);
},
};
const guildDataService = {
getGuildSystem: async () => {
if (!params.designatedGuildExists) {
throw new Error('unknown guild');
}
return {} as never;
},
createGuild: async ({data}: {data: {name: string}}) => {
createdNames.push(data.name);
nextCreatedGuildId += 1n;
return {id: nextCreatedGuildId.toString()} as never;
},
};
const service = new SingleCommunityService(
instanceConfigRepository as never,
guildDataService as never,
null as never,
);
return {service, written, createdNames};
}
describe('SingleCommunityService.ensureStockCommunity', () => {
it('reuses the designated community when it still exists', async () => {
const harness = createHarness({designatedGuildId: EXISTING_GUILD_ID, designatedGuildExists: true});
const guildId = await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
expect(guildId.toString()).toBe(EXISTING_GUILD_ID);
expect(harness.createdNames).toEqual([]);
expect(harness.written).toEqual([{single_community_enabled: true, single_community_guild_id: EXISTING_GUILD_ID}]);
});
it('creates a fresh community when the designated one was deleted', async () => {
const harness = createHarness({designatedGuildId: EXISTING_GUILD_ID, designatedGuildExists: false});
const guildId = await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
expect(guildId.toString()).not.toBe(EXISTING_GUILD_ID);
expect(harness.createdNames).toEqual(['Fluxer']);
});
it('creates a fresh community when the instance never designated one', async () => {
const harness = createHarness({designatedGuildId: null, designatedGuildExists: false});
await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
expect(harness.createdNames).toEqual(['Fluxer']);
});
it('creates a fresh community when the stored guild id is not a snowflake', async () => {
const harness = createHarness({designatedGuildId: 'not-a-snowflake', designatedGuildExists: true});
await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
expect(harness.createdNames).toEqual(['Fluxer']);
});
});
@@ -508,7 +508,6 @@ const AppPublicConfigUpdateRequest = z.object({
const InstancePolicyResponse = z.object({
single_community_enabled: z.boolean(),
single_community_locked: z.boolean(),
single_community_guild_id: z.string().nullable(),
direct_messages_disabled: z.boolean(),
direct_messages_locked: z.boolean(),