diff --git a/README.md b/README.md
index a8b95bb..2bb96a6 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,7 @@ The defaults are starting points and are fully editable in the dashboard.
| Feature | Free | Plus | Pro |
|---|---:|---:|---:|
+| Groups available | Yes | Yes | Yes |
| Group members, including owner | 2 | 4 | 8 |
| Recording status send/view | No | Yes | Yes |
| Automatic voice send | No | Yes | Yes |
@@ -77,7 +78,7 @@ Each tier can independently configure its price, currency, billing interval, Str
Administrators can assign a tier directly from **Admin > Users > Edit > Tier Override**. The override grants that tier's features without changing Stripe billing data and remains in effect until it is changed back to **Follow Stripe / billing status**.
-Private groups can be enabled or disabled globally under **Admin > Plans & Platform > Group Availability**. Disabling groups hides their navigation and channels and blocks group API access without deleting existing groups or messages.
+Private groups can be enabled or disabled globally under **Admin > Plans & Platform > Group Availability** or **Admin > Config > Feature Availability**. Each tier also has a **Groups Available** entitlement. Disabling either control hides group navigation and channels and blocks group API access without deleting existing groups or messages.
## Mailgun
diff --git a/admin.php b/admin.php
index 0c36eb3..459aa0e 100644
--- a/admin.php
+++ b/admin.php
@@ -180,14 +180,16 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
csrfCheck();
$act = $_POST['action'] ?? '';
- if (in_array($act, ['save_platform_services','save_plans','create_plan','run_mysql_mirror'], true)) {
+ if (in_array($act, ['save_group_availability','save_platform_services','save_plans','create_plan','run_mysql_mirror'], true)) {
try {
$message = handleAdminPlatformAction($act);
flash($message ?: 'Platform action completed.');
} catch (Throwable $e) {
flash($e->getMessage(), 'err');
}
- redirect('tab=platform');
+ $returnTab = $act === 'save_group_availability' && ($_POST['return_tab'] ?? '') === 'config'
+ ? 'config' : 'platform';
+ redirect('tab=' . $returnTab);
}
// ── Messages ──────────────────────────────────────────────────────────────
@@ -1671,6 +1673,22 @@ td.actions{white-space:nowrap;width:1px}
// config.json
@@ -1706,6 +1724,7 @@ td.actions{white-space:nowrap;width:1px}
voice.max_duration_seconds integer Maximum voice clip duration
voice.max_upload_bytes integer Maximum recording upload size
voice.auto_play_default bool Queue and play incoming clips sequentially by default
+ groups.enabled bool Global master switch for private groups
security.min_password_length integer Min password chars
security.session_timeout_hours integer Session expiry in hours
security.bcrypt_cost integer bcrypt work factor (10–14)
diff --git a/api/groups.php b/api/groups.php
index 04bfdf3..81032c9 100644
--- a/api/groups.php
+++ b/api/groups.php
@@ -6,11 +6,25 @@ sendCorsHeaders();
$user = authRequired();
$action = $_POST['action'] ?? $_GET['action'] ?? 'list';
-if (!groupsEnabled()) {
+$planGroupsEnabled = (bool)featureValue($user, 'groups_available', true);
+if (!groupsAvailableForUser($user)) {
+ $error = groupsEnabled()
+ ? 'Private groups are not available on your tier'
+ : 'Private groups are currently disabled';
if ($action === 'list') {
- jsonResponse(['groups' => [], 'member_limit' => 0, 'enabled' => false]);
+ jsonResponse([
+ 'groups' => [],
+ 'member_limit' => 0,
+ 'enabled' => false,
+ 'platform_enabled' => groupsEnabled(),
+ 'plan_enabled' => $planGroupsEnabled,
+ ]);
}
- jsonResponse(['error' => 'Private groups are currently disabled', 'groups_enabled' => false], 403);
+ jsonResponse([
+ 'error' => $error,
+ 'groups_enabled' => false,
+ 'groups_platform_enabled' => groupsEnabled(),
+ ], 403);
}
switch ($action) {
@@ -19,6 +33,8 @@ switch ($action) {
'groups' => userGroups((int)$user['id']),
'member_limit' => (int)featureValue($user, 'group_member_limit', 2),
'enabled' => true,
+ 'platform_enabled' => true,
+ 'plan_enabled' => true,
]);
case 'create':
diff --git a/api/messages.php b/api/messages.php
index 48766ab..00d046c 100644
--- a/api/messages.php
+++ b/api/messages.php
@@ -33,8 +33,15 @@ function messagePayload(array $row): array {
function requestedGroup(array $user): ?int {
$groupId = normalizeGroupId($_POST['group_id'] ?? $_GET['group_id'] ?? null);
- if ($groupId !== null && !groupsEnabled()) {
- jsonResponse(['error' => 'Private groups are currently disabled', 'groups_enabled' => false], 403);
+ if ($groupId !== null && !groupsAvailableForUser($user)) {
+ $error = groupsEnabled()
+ ? 'Private groups are not available on your tier'
+ : 'Private groups are currently disabled';
+ jsonResponse([
+ 'error' => $error,
+ 'groups_enabled' => false,
+ 'groups_platform_enabled' => groupsEnabled(),
+ ], 403);
}
requireGroupAccess($user, $groupId);
return $groupId;
@@ -174,8 +181,9 @@ function pollMessages(): never {
'messages' => array_map('messagePayload', $rows),
'online' => (int)$onlineStmt->fetchColumn(),
'recording' => $recording,
- 'groups' => groupsEnabled() ? userGroups((int)$user['id']) : [],
- 'groups_enabled' => groupsEnabled(),
+ 'groups' => groupsAvailableForUser($user) ? userGroups((int)$user['id']) : [],
+ 'groups_enabled' => groupsAvailableForUser($user),
+ 'groups_platform_enabled' => groupsEnabled(),
'group_id' => $groupId,
'server_time' => time(),
]);
diff --git a/assets/js/cyberchat-v4.js b/assets/js/cyberchat-v4.js
index 9ee01fa..c9fcc3d 100644
--- a/assets/js/cyberchat-v4.js
+++ b/assets/js/cyberchat-v4.js
@@ -6,7 +6,7 @@
const state = {
config: {}, user: null, groups: [], billing: null,
view: ['chat', 'groups', 'archive', 'account'].includes(initialView) ? initialView : 'chat',
- groupId: null, lastId: 0, pollTimer: null, polling: false, pollGeneration: 0,
+ groupId: null, lastId: 0, pollTimer: null, polling: false, pollGeneration: 0, groupsAccess: null,
groupSeen: {}, groupUnread: {},
loginChallenge: initialChallenge,
verifyToken: new URLSearchParams(location.search).get('verify') || '',
@@ -47,12 +47,13 @@
? state.user.features[key] : fallback;
}
function groupsAvailable() {
- return state.config.groups?.enabled !== false;
+ return state.config.groups?.enabled !== false && state.groupsAccess !== false;
}
- function applyGroupsAvailability(enabled, groups = []) {
+ function applyGroupsAvailability(enabled, groups = [], platformEnabled = enabled) {
const next = enabled !== false;
const changed = groupsAvailable() !== next;
- state.config.groups = { ...(state.config.groups || {}), enabled: next };
+ state.groupsAccess = next;
+ state.config.groups = { ...(state.config.groups || {}), enabled: platformEnabled !== false };
if (next) {
if (changed) syncGroups(groups);
} else {
@@ -64,8 +65,8 @@
}
return changed;
}
- function disableGroups(message = '') {
- applyGroupsAvailability(false);
+ function disableGroups(message = '', platformEnabled = state.config.groups?.enabled !== false) {
+ applyGroupsAvailability(false, [], platformEnabled);
if (message) toast(message);
app();
}
@@ -263,13 +264,11 @@
const [groups, billing] = await Promise.all([
groupsAvailable()
? api('groups.php', null, 'action=list').catch(() => ({ groups: [] }))
- : Promise.resolve({ groups: [], enabled: false }),
+ : Promise.resolve({ groups: [], enabled: false, platform_enabled: false }),
api('billing.php', null, 'action=status').catch(() => null)
]);
- state.config.groups = {
- ...(state.config.groups || {}),
- enabled: groups.enabled !== false && groupsAvailable(),
- };
+ state.groupsAccess = groups.enabled !== false;
+ state.config.groups = { ...(state.config.groups || {}), enabled: groups.platform_enabled !== false };
state.groups = groupsAvailable() ? (groups.groups || []) : [];
state.groupSeen = {};
state.groupUnread = {};
@@ -387,7 +386,7 @@
const result = await api('messages.php', form({ action: 'send', message, group_id: state.groupId || '' }));
if (result.groups_enabled === false) {
input.value = message;
- return disableGroups(result.error);
+ return disableGroups(result.error, result.groups_platform_enabled !== false);
}
if (result.error) { input.value = message; return toast(result.error); }
appendMessage(result.message);
@@ -415,7 +414,11 @@
const result = await api('messages.php', null, query.toString()).catch(() => null);
if (generation !== state.pollGeneration || Number(state.groupId || 0) !== Number(requestedGroup || 0)) return;
if (result && !result.error && Number(result.group_id || 0) === Number(requestedGroup || 0)) {
- const availabilityChanged = applyGroupsAvailability(result.groups_enabled !== false, result.groups || []);
+ const availabilityChanged = applyGroupsAvailability(
+ result.groups_enabled !== false,
+ result.groups || [],
+ result.groups_platform_enabled !== false
+ );
if (availabilityChanged) {
toast(groupsAvailable() ? 'Private groups are now available' : 'Private groups have been disabled', 'success');
app();
@@ -449,7 +452,10 @@
const result = await api('groups.php', null, 'action=list').catch(() => null);
if (!result || result.error || generation !== state.pollGeneration) return;
if (result.enabled === false) {
- disableGroups('Private groups have been disabled');
+ disableGroups(
+ result.platform_enabled === false ? 'Private groups have been disabled' : 'Private groups are not available on your tier',
+ result.platform_enabled !== false
+ );
return;
}
syncGroups(result.groups || []);
@@ -604,7 +610,7 @@
body.append('voice', state.voice.blob, 'voice.webm');
const result = await api('messages.php', { method: 'POST', body });
state.voice.sending = false;
- if (result.groups_enabled === false) return disableGroups(result.error);
+ if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
if (result.error) return toast(result.error);
appendMessage(result.message);
state.lastId = Math.max(state.lastId, result.message.id);
@@ -643,7 +649,10 @@
api('groups.php', null, 'action=list').then(result => {
if (result.error || state.view !== 'groups') return;
if (result.enabled === false) {
- disableGroups('Private groups have been disabled');
+ disableGroups(
+ result.platform_enabled === false ? 'Private groups have been disabled' : 'Private groups are not available on your tier',
+ result.platform_enabled !== false
+ );
return;
}
const before = JSON.stringify(state.groups);
@@ -662,21 +671,21 @@
}
async function createGroup() {
const result = await api('groups.php', form({ action: 'create', name: $('#group-name').value.trim(), members: $('#group-members').value.trim() }));
- if (result.groups_enabled === false) return disableGroups(result.error);
+ if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
if (result.error) return toast(result.error);
state.groups = result.groups;
groupsView();
}
async function addMember(groupId) {
const result = await api('groups.php', form({ action: 'add_member', group_id: groupId, username: $('#add-' + groupId).value.trim() }));
- if (result.groups_enabled === false) return disableGroups(result.error);
+ if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
if (result.error) return toast(result.error);
state.groups = result.groups;
groupsView();
}
async function removeMember(groupId, userId) {
const result = await api('groups.php', form({ action: 'remove_member', group_id: groupId, user_id: userId }));
- if (result.groups_enabled === false) return disableGroups(result.error);
+ if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
if (result.error) return toast(result.error);
state.groups = result.groups;
groupsView();
@@ -684,7 +693,7 @@
async function deleteGroup(groupId) {
if (!confirm('Delete this group and its messages?')) return;
const result = await api('groups.php', form({ action: 'delete', group_id: groupId }));
- if (result.groups_enabled === false) return disableGroups(result.error);
+ if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
if (result.error) return toast(result.error);
state.groups = result.groups;
if (state.groupId === groupId) state.groupId = null;
@@ -807,6 +816,7 @@
state.groups = [];
state.billing = null;
state.groupId = null;
+ state.groupsAccess = null;
state.groupSeen = {};
state.groupUnread = {};
state.voice.playQueue = [];
diff --git a/bootstrap.php b/bootstrap.php
index c1dbfbe..d1827b2 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -73,6 +73,10 @@ function groupsEnabled(): bool {
return (bool)getConfigVal('groups.enabled', true);
}
+function groupsAvailableForUser(array $user): bool {
+ return groupsEnabled() && (bool)featureValue($user, 'groups_available', true);
+}
+
function setConfigValues(array $changes): void {
$config = getConfig();
foreach ($changes as $path => $value) {
@@ -353,7 +357,7 @@ function seedPlans(PDO $db): void {
'price' => 0, 'sort' => 0,
'features' => [
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false,
- 'auto_voice_play' => false,
+ 'auto_voice_play' => false, 'groups_available' => true,
'group_member_limit' => 2, 'username_color' => false, 'text_archive_days' => 30,
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
],
@@ -363,7 +367,7 @@ function seedPlans(PDO $db): void {
'price' => 499, 'sort' => 10,
'features' => [
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true,
- 'auto_voice_play' => true,
+ 'auto_voice_play' => true, 'groups_available' => true,
'group_member_limit' => 4, 'username_color' => true, 'text_archive_days' => 90,
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
],
@@ -373,7 +377,7 @@ function seedPlans(PDO $db): void {
'price' => 999, 'sort' => 20,
'features' => [
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true,
- 'auto_voice_play' => true,
+ 'auto_voice_play' => true, 'groups_available' => true,
'group_member_limit' => 8, 'username_color' => true, 'text_archive_days' => 365,
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
],
@@ -393,6 +397,8 @@ function seedPlans(PDO $db): void {
$insertFeature->execute([$planId, $key, json_encode($value)]);
}
}
+ $db->exec("INSERT OR IGNORE INTO plan_features (plan_id,feature_key,value_json)
+ SELECT id,'groups_available','true' FROM plans");
$freeId = (int)$db->query("SELECT id FROM plans WHERE slug = 'free'")->fetchColumn();
$db->prepare("UPDATE users SET plan_id = ? WHERE plan_id IS NULL")->execute([$freeId]);
}
diff --git a/embed-example.html b/embed-example.html
index 68f7679..c77d6b3 100644
--- a/embed-example.html
+++ b/embed-example.html
@@ -6,7 +6,7 @@
CyberChat — Embed Example
-
+