- 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.
This commit is contained in:
Ty Clifford
2026-06-08 16:47:02 -04:00
parent 2881a3b338
commit de8ec43aa4
9 changed files with 107 additions and 42 deletions
+2 -1
View File
@@ -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
+21 -2
View File
@@ -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}
<p>// edit config.json live — changes take effect immediately</p>
</div>
<div class="panel">
<div class="panel-head"><span class="panel-title">// FEATURE AVAILABILITY</span></div>
<div class="panel-body">
<form method="post">
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
<input type="hidden" name="action" value="save_group_availability">
<input type="hidden" name="return_tab" value="config">
<label class="cb-row">
<input type="checkbox" name="groups_enabled" <?= groupsEnabled() ? 'checked' : '' ?>>
<span><strong>Groups Enabled</strong> — master switch for private group navigation, channels, and API access.</span>
</label>
<button type="submit" class="btn btn-g">SAVE GROUP AVAILABILITY</button>
</form>
</div>
</div>
<div class="panel">
<div class="panel-head">
<span class="panel-title">// config.json</span>
@@ -1706,6 +1724,7 @@ td.actions{white-space:nowrap;width:1px}
<tr><td class="mono">voice.max_duration_seconds</td><td class="dim">integer</td><td>Maximum voice clip duration</td></tr>
<tr><td class="mono">voice.max_upload_bytes</td><td class="dim">integer</td><td>Maximum recording upload size</td></tr>
<tr><td class="mono">voice.auto_play_default</td><td class="dim">bool</td><td>Queue and play incoming clips sequentially by default</td></tr>
<tr><td class="mono">groups.enabled</td><td class="dim">bool</td><td>Global master switch for private groups</td></tr>
<tr><td class="mono">security.min_password_length</td><td class="dim">integer</td><td>Min password chars</td></tr>
<tr><td class="mono">security.session_timeout_hours</td><td class="dim">integer</td><td>Session expiry in hours</td></tr>
<tr><td class="mono">security.bcrypt_cost</td><td class="dim">integer</td><td>bcrypt work factor (1014)</td></tr>
+19 -3
View File
@@ -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':
+12 -4
View File
@@ -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(),
]);
+30 -20
View File
@@ -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 = [];
+9 -3
View File
@@ -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]);
}
+4 -4
View File
@@ -6,7 +6,7 @@
<title>CyberChat — Embed Example</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.3">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.4">
<style>
html, body {
height: auto;
@@ -88,13 +88,13 @@
<span class="kw">&lt;link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&amp;family=Rajdhani:wght@400;600;700&amp;family=Orbitron:wght@700;900&amp;display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 2. CyberChat stylesheet --&gt;</span>
<span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260608.3"</span><span class="kw">&gt;</span>
<span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260608.4"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 3. Container with any dimensions --&gt;</span>
<span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">&gt;&lt;/div&gt;</span>
<span class="cm">&lt;!-- 4. Script + init --&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-v4.js?v=20260608.3"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-v4.js?v=20260608.4"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script&gt;</span>
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
CyberChat.init(<span class="str">'#my-chat'</span>);
@@ -104,7 +104,7 @@
<script>
window.CYBERCHAT_BASE = '';
</script>
<script src="assets/js/cyberchat-v4.js?v=20260608.3"></script>
<script src="assets/js/cyberchat-v4.js?v=20260608.4"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#chat-here');
+2 -2
View File
@@ -7,14 +7,14 @@
<title>Chat @ TyClifford.com</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.3">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.4">
</head>
<body>
<div id="app"></div>
<script>
window.CYBERCHAT_BASE = '';
</script>
<script src="assets/js/cyberchat-v4.js?v=20260608.3"></script>
<script src="assets/js/cyberchat-v4.js?v=20260608.4"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#app');
+8 -3
View File
@@ -1,6 +1,11 @@
<?php
function handleAdminPlatformAction(string $action): ?string {
if ($action === 'save_group_availability') {
setConfigValues(['groups.enabled' => !empty($_POST['groups_enabled'])]);
return 'Group availability saved.';
}
if ($action === 'save_platform_services') {
setConfigValues([
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
@@ -28,7 +33,7 @@ function handleAdminPlatformAction(string $action): ?string {
$db = getDB();
$plans = $_POST['plans'] ?? [];
$boolFeatures = [
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'username_color',
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'groups_available', 'username_color',
'text_export', 'voice_archive', 'voice_export', 'email_2fa',
];
$db->beginTransaction();
@@ -82,7 +87,7 @@ function handleAdminPlatformAction(string $action): ?string {
$id = (int)$db->lastInsertId();
$features = [
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false,
'auto_voice_play' => false,
'auto_voice_play' => false, 'groups_available' => false,
'group_member_limit' => 2, 'username_color' => false, 'text_archive_days' => 30,
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
];
@@ -120,7 +125,7 @@ function renderAdminPlatformTab(): void {
$events = $db->query("SELECT event_type,COUNT(*) AS total FROM visitor_events
WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll();
$featureKeys = [
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'group_member_limit',
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'groups_available', 'group_member_limit',
'username_color', 'text_archive_days', 'text_export', 'voice_archive',
'voice_export', 'email_2fa',
];