Files
reflector-dashboard/auth/profile.php
T

285 lines
16 KiB
PHP

<?php
/*
* Reflector Dashboard — web front-end for SvxReflector
* Copyright (C) 2026 SA7AUX (Björn)
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version. See the LICENSE file for details.
*/
declare(strict_types=1);
require __DIR__ . '/lib.php';
start_session();
$uuid = $_SESSION['uid'] ?? null;
if (!is_string($uuid) || $uuid === '') { header('Location: /'); exit; }
$me = acct_get($uuid);
if (!$me) { header('Location: /'); exit; }
$msg = ''; $err = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'save_profile') {
$allowed = ['callsign','firstname','lastname','shortname','email','city','state','country'];
$updates = [];
foreach ($allowed as $f) {
if (isset($_POST[$f]) && is_string($_POST[$f])) $updates[$f] = trim($_POST[$f]);
}
if (isset($updates['callsign'])) {
$old_cs = strtoupper(trim($me['callsign'] ?? ''));
$new_cs = base_call($updates['callsign']);
$updates['callsign'] = $new_cs;
if ($old_cs !== $new_cs) {
if ($old_cs !== '') redis()->del(call_index_key($old_cs));
redis()->set(call_index_key($new_cs), $uuid);
}
}
if (isset($updates['email'])) {
$old_em = strtolower(trim($me['email'] ?? ''));
$new_em = norm_email($updates['email']);
$updates['email'] = $new_em;
if ($old_em !== $new_em) {
if ($old_em !== '') redis()->del(email_index_key($old_em));
redis()->set(email_index_key($new_em), $uuid);
}
}
if (!empty($updates)) {
redis()->hMSet(acct_key($uuid), $updates);
$me = acct_get($uuid);
$msg = 'Saved!';
}
} elseif ($action === 'change_password') {
$old = (string)($_POST['old_password'] ?? '');
$new = (string)($_POST['new_password'] ?? '');
$new2= (string)($_POST['new_password2'] ?? '');
$hash = (string)($me['password_hash'] ?? '');
if ($old === '' || $new === '') { $msg = 'Fill all fields'; $err = true; }
elseif ($new !== $new2) { $msg = 'Passwords do not match'; $err = true; }
elseif (strlen($new) < 6) { $msg = 'Password too short (min 6 chars)'; $err = true; }
elseif (!password_verify($old, $hash)) { $msg = 'Invalid current password'; $err = true; }
else {
redis()->hSet(acct_key($uuid), 'password_hash', password_hash($new, PASSWORD_DEFAULT));
$msg = 'Password changed!';
}
}
}
// h() comes from auth/lib/html.php.
$call = (string)($me['callsign'] ?? '');
$first = (string)($me['firstname'] ?? '');
$last = (string)($me['lastname'] ?? '');
$short = (string)($me['shortname'] ?? '');
$email = (string)($me['email'] ?? '');
$city = (string)($me['city'] ?? '');
$state = (string)($me['state'] ?? '');
$country = (string)($me['country'] ?? '');
$flags = acct_flags_from($me);
$admin = acct_level_from_flags($flags);
$email_verified = ((($me['email_verified'] ?? '0')) === '1');
$parts = [];
foreach ([1=>'unreg',2=>'reg',4=>'normal',8=>'lvl1',16=>'lvl2',32=>'lvl3'] as $bit => $name) {
if ($flags & $bit) $parts[] = $name;
}
$lvlText = $parts ? implode(', ', $parts) : '-';
$lvlColor = $admin >= 2 ? '#ffc800' : 'rgba(0,255,255,.75)';
$star = $admin >= 2 ? ' &#9733;' : '';
// Callsigns tab (read-only): the account's callsigns (primary + admin-added
// extras/club calls) and the nodes where one of them is a listed node admin.
// Only site admins can change these lists.
$my_calls = acct_all_calls($uuid);
$extra_calls = array_values(array_filter(array_map(
fn($c) => strtoupper(trim((string)$c)),
redis()->sMembers(acct_calls_key($uuid)) ?: []
)));
sort($extra_calls);
$admin_nodes = [];
$rds = redis();
$cursor = null;
do {
$res = $rds->scan($cursor, rkey('node_admins:*'), 200);
if ($res === false) break;
foreach ($res as $key) {
$node = substr($key, strlen(rkey('node_admins:')));
$members = $rds->sMembers($key) ?: [];
foreach ($my_calls as $c) {
if (in_array($c, $members, true)) { $admin_nodes[] = $node; break; }
}
}
} while ($cursor > 0);
$admin_nodes = array_values(array_unique($admin_nodes));
sort($admin_nodes);
$page_title = 'Profile — Reflector Dashboard';
require __DIR__ . '/header.php';
?>
<div class="container py-4" style="max-width:800px;">
<?php if ($msg): ?>
<div class="panel" style="border-color: <?= $err ? '#ff8080' : '#00ff80' ?>;">
<span style="color: <?= $err ? '#ff8080' : '#00ff80' ?>;"><?=h($msg)?></span>
</div>
<?php endif; ?>
<nav class="profile-tabs" role="tablist">
<button type="button" class="profile-tab active" data-ptab="account" role="tab">
<svg width="18" height="18" fill="currentColor" viewBox="0 0 16 16"><path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z"/></svg>
<span>My Account</span>
</button>
<button type="button" class="profile-tab" data-ptab="nodes" role="tab">
<svg width="18" height="18" fill="currentColor" viewBox="0 0 16 16"><path d="M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5"/><path d="M10 8a2 2 0 1 1-4 0V3a2 2 0 1 1 4 0zM8 0a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V3a3 3 0 0 0-3-3"/></svg>
<span>My Nodes</span>
</button>
<button type="button" class="profile-tab" data-ptab="nodesec" role="tab">
<svg width="18" height="18" fill="currentColor" viewBox="0 0 16 16"><path d="M8 0c-.69 0-1.843.265-2.928.56-1.11.3-2.229.655-2.887.87a1.54 1.54 0 0 0-1.044 1.262c-.596 4.477.787 7.795 2.465 9.99a11.8 11.8 0 0 0 2.517 2.453c.386.273.744.482 1.048.625.28.132.581.24.829.24s.548-.108.829-.24a7 7 0 0 0 1.048-.625 11.8 11.8 0 0 0 2.517-2.453c1.678-2.195 3.061-5.513 2.465-9.99a1.54 1.54 0 0 0-1.044-1.263 63 63 0 0 0-2.887-.87C9.843.266 8.69 0 8 0m0 5a1.5 1.5 0 0 1 .5 2.915l.385 1.99a.5.5 0 0 1-.491.595h-.788a.5.5 0 0 1-.49-.595l.384-1.99A1.5 1.5 0 0 1 8 5"/></svg>
<span>Node Security</span>
</button>
<button type="button" class="profile-tab" data-ptab="calls" role="tab">
<svg width="18" height="18" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m-3 1a1 1 0 1 0 0-2 1 1 0 0 0 0 2m0 4a1 1 0 1 0 0-2 1 1 0 0 0 0 2m0 4a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/></svg>
<span>Callsigns</span>
</button>
<button type="button" class="profile-tab" data-ptab="mqtt" role="tab">
<svg width="18" height="18" fill="currentColor" viewBox="0 0 16 16"><path d="M2.146 2.146a.5.5 0 0 1 .708 0C4.63 3.921 5.5 6.14 5.5 8s-.87 4.079-2.646 5.854a.5.5 0 0 1-.708-.708C3.87 11.579 4.5 9.66 4.5 8s-.63-3.579-2.354-5.146a.5.5 0 0 1 0-.708m11.708 0a.5.5 0 0 1 .708.708C12.63 4.421 12 6.34 12 8s.63 3.579 2.562 5.146a.5.5 0 0 1-.708.708C12.37 12.079 11.5 9.86 11.5 8s.87-4.079 2.354-5.854M8 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3"/></svg>
<span>Remote (MQTT)</span>
</button>
<button type="button" class="profile-tab" data-ptab="password" role="tab">
<svg width="18" height="18" fill="currentColor" viewBox="0 0 16 16"><path d="M0 8a4 4 0 0 1 7.465-2H14a.5.5 0 0 1 .354.146l1.5 1.5a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0L13 9.207l-.646.647a.5.5 0 0 1-.708 0L11 9.207l-.646.647a.5.5 0 0 1-.708 0L9 9.207l-.646.647A.5.5 0 0 1 8 10h-.535A4 4 0 0 1 0 8m4-3a3 3 0 1 0 2.959 3.5H8a.5.5 0 0 0 .354-.146l.853-.854 1.146 1.147a.5.5 0 0 0 .708 0l.646-.647.646.647a.5.5 0 0 0 .708 0l.646-.647.646.647a.5.5 0 0 0 .708 0L15 7.207l-1-1H7.465A3 3 0 0 0 4 5m-1 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/></svg>
<span>Password</span>
</button>
</nav>
<div id="ptab-account" class="profile-tab-pane">
<div class="panel">
<div class="mb-3 d-flex gap-3 align-items-center flex-wrap">
<span class="small text-secondary">UUID: <span class="mono"><?=h($uuid)?></span></span>
<span class="small" style="color:<?=$lvlColor?>;"><?=h($lvlText)?><?=$star?></span>
</div>
<form method="post" action="">
<input type="hidden" name="action" value="save_profile">
<h6 class="mt-2 mb-2" style="color:var(--fg2);">Identity</h6>
<div class="d-flex gap-2 align-items-center mb-1">
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_callsign">Callsign</label>
<input class="form-control form-control-sm mono" id="f_callsign" name="callsign" value="<?=h($call)?>" style="max-width:400px;">
</div>
<div class="d-flex gap-2 align-items-center mb-1">
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_firstname">First Name</label>
<input class="form-control form-control-sm mono" id="f_firstname" name="firstname" value="<?=h($first)?>" style="max-width:400px;">
</div>
<div class="d-flex gap-2 align-items-center mb-1">
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_lastname">Last Name</label>
<input class="form-control form-control-sm mono" id="f_lastname" name="lastname" value="<?=h($last)?>" style="max-width:400px;">
</div>
<div class="d-flex gap-2 align-items-center mb-1">
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_shortname">Short Name</label>
<input class="form-control form-control-sm mono" id="f_shortname" name="shortname" value="<?=h($short)?>" style="max-width:400px;">
</div>
<div class="d-flex gap-2 align-items-center mb-1">
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_email">Email</label>
<input class="form-control form-control-sm mono" id="f_email" name="email" value="<?=h($email)?>" style="max-width:400px;">
</div>
<div class="d-flex gap-2 align-items-center mb-1" id="emailVerifyRow">
<span class="small text-secondary" style="min-width:200px;text-align:right;">Email status</span>
<span>
<?php if ($email_verified): ?>
<span class="node-badge" style="border-color:#00c864;color:#00c864;">&#10003; Verified</span>
<?php else: ?>
<span class="node-badge" style="border-color:#ffc800;color:#ffc800;">Not verified</span>
<button type="button" class="btn btn-outline-secondary btn-sm ms-2" id="btnResendVerify">Send verification email</button>
<?php endif; ?>
<span id="resendMsg" class="ms-2 small"></span>
</span>
</div>
<h6 class="mt-3 mb-2" style="color:var(--fg2);">Location</h6>
<div class="d-flex gap-2 align-items-center mb-1">
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_city">City</label>
<input class="form-control form-control-sm mono" id="f_city" name="city" value="<?=h($city)?>" style="max-width:400px;">
</div>
<div class="d-flex gap-2 align-items-center mb-1">
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_state">State</label>
<input class="form-control form-control-sm mono" id="f_state" name="state" value="<?=h($state)?>" style="max-width:400px;">
</div>
<div class="d-flex gap-2 align-items-center mb-1">
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_country">Country</label>
<input class="form-control form-control-sm mono" id="f_country" name="country" value="<?=h($country)?>" style="max-width:400px;">
</div>
<button class="btn btn-primary btn-sm mt-3" type="submit">Save</button>
</form>
</div>
</div>
<div id="ptab-nodes" class="profile-tab-pane" style="display:none;">
<div class="panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">My Nodes</h5>
<div class="d-flex gap-2 align-items-center">
<div id="nodeSelWrap" class="d-flex gap-2 align-items-center" style="display:none;">
<label class="small text-secondary mb-0">Node:</label>
<select class="form-select form-select-sm" id="nodeSelect" style="max-width:180px;"></select>
</div>
<span id="nodesStatus" class="small text-secondary">Loading...</span>
</div>
</div>
<div id="nodesList"></div>
</div>
</div>
<div id="ptab-nodesec" class="profile-tab-pane" style="display:none;">
<div class="panel">
<h5 class="mb-3">Node Security</h5>
<p class="small text-secondary">All reflector nodes you administer — by your callsign, as a listed node admin, or via your account email in the node's certificate (shown even when offline). The login password goes in the node's <span class="mono">AUTH_KEY</span>; copy it below. A reset restarts the reflector.</p>
<div id="nodeAuthList"><span class="small text-secondary">Loading…</span></div>
</div>
</div>
<div id="ptab-calls" class="profile-tab-pane" style="display:none;">
<div class="panel">
<h5 class="mb-3">Callsigns</h5>
<p class="small text-secondary">The callsigns you are admin for. Nodes whose name contains any of these callsigns belong to your account. Only site admins can change these lists.</p>
<h6 class="mb-2" style="color:var(--fg2);">Account callsigns</h6>
<div class="mb-3">
<?php if ($call !== ''): ?><span class="node-badge mono me-1"><?=h(strtoupper($call))?> &middot; primary</span><?php endif; ?>
<?php foreach ($extra_calls as $xc): ?><span class="node-badge mono me-1"><?=h($xc)?></span><?php endforeach; ?>
<?php if ($call === '' && !$extra_calls): ?><span class="small text-secondary">None.</span><?php endif; ?>
</div>
<h6 class="mb-2" style="color:var(--fg2);">Node admin for</h6>
<div>
<?php if ($admin_nodes): foreach ($admin_nodes as $an): ?><span class="node-badge mono me-1"><?=h($an)?></span><?php endforeach; else: ?><span class="small text-secondary">None &mdash; you are not listed as an extra admin on any node.</span><?php endif; ?>
</div>
</div>
</div>
<div id="ptab-mqtt" class="profile-tab-pane" style="display:none;">
<div class="panel">
<h5 class="mb-3">Remote control (MQTT)</h5>
<div class="text-secondary small mb-3">Generate the MQTT credentials for your node, then paste them into the node dashboard → <b>Admin → Remote Ctrl</b> and press <b>Turn ON</b>. This lets the reflector portal remote-set your node's talkgroup over an encrypted, outbound link (works behind NAT — no port-forwarding).</div>
<div id="mqttPane"><span class="small text-secondary">Loading…</span></div>
</div>
</div>
<div id="ptab-password" class="profile-tab-pane" style="display:none;">
<div class="panel">
<h6 class="mb-3">Reset Web Password</h6>
<form method="post" action="">
<input type="hidden" name="action" value="change_password">
<label class="form-label small text-secondary" for="f_old">Current password</label>
<input type="password" class="form-control form-control-sm mb-2" id="f_old" name="old_password">
<label class="form-label small text-secondary" for="f_new">New password</label>
<input type="password" class="form-control form-control-sm mb-2" id="f_new" name="new_password">
<label class="form-label small text-secondary" for="f_new2">Repeat new password</label>
<input type="password" class="form-control form-control-sm mb-2" id="f_new2" name="new_password2">
<button class="btn btn-warning btn-sm mt-2" type="submit">Change Password</button>
</form>
</div>
</div>
</div>
<?php
$extra_scripts = '<script src="/auth/profile_nodes.js?v=' . @filemtime(__DIR__ . '/profile_nodes.js') . '"></script>';
require __DIR__ . '/footer.php';
?>