From de8ec43aa4bbb950518df5483d4ed334e395c085 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Mon, 8 Jun 2026 16:47:02 -0400 Subject: [PATCH] - Fixed. The missing controls now appear at: Plans & Platform > Plans, Prices & Entitlements > Groups Available for each tier. Config > Feature Availability > Groups Enabled as the global master switch. --- README.md | 3 ++- admin.php | 23 ++++++++++++++++-- api/groups.php | 22 ++++++++++++++--- api/messages.php | 16 +++++++++---- assets/js/cyberchat-v4.js | 50 +++++++++++++++++++++++---------------- bootstrap.php | 12 +++++++--- embed-example.html | 8 +++---- index.html | 4 ++-- lib/admin_platform.php | 11 ++++++--- 9 files changed, 107 insertions(+), 42 deletions(-) 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}

// edit config.json live — changes take effect immediately

+
+
// FEATURE AVAILABILITY
+
+
+ + + + + +
+
+
+
// config.json @@ -1706,6 +1724,7 @@ td.actions{white-space:nowrap;width:1px} voice.max_duration_secondsintegerMaximum voice clip duration voice.max_upload_bytesintegerMaximum recording upload size voice.auto_play_defaultboolQueue and play incoming clips sequentially by default + groups.enabledboolGlobal master switch for private groups security.min_password_lengthintegerMin password chars security.session_timeout_hoursintegerSession expiry in hours security.bcrypt_costintegerbcrypt 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 - +