Dashboard account section, profile API, and CSS account panels

- SSO session auth gating on all protected pages
- dashboard.php: account section (profile form + workspace panel),
  onboarding prompt modal, overview bar extracted to CSS classes,
  dashboard.css linked in page head
- api/profile.php: save/dismiss endpoint for optional profile fields
- assets/css/dashboard.css: account grid, dash-account-panel,
  dash-profile-form, profile-prompt-backdrop modal, overview bar
  classes, dash-section-kicker, dash-tier-badge base styles
- includes/bootstrap.php: dbnToolsMainUserProfile,
  dbnToolsProfileNeedsPrompt, dbnToolsRequirePageAuth
- scripts/sql/004_user_profile_fields.sql: nullable phone, address,
  and profile_prompt_dismissed_at columns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 18:49:34 +02:00
parent 302bb44f70
commit b912ff22bc
16 changed files with 718 additions and 58 deletions
+208 -15
View File
@@ -4,10 +4,7 @@ declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
require_once __DIR__ . '/includes/FreeTier.php';
if (!dbnToolsIsAuthenticated()) {
header('Location: /?return=' . urlencode($_SERVER['REQUEST_URI'] ?? '/dashboard.php'));
exit;
}
dbnToolsRequirePageAuth($_SERVER['REQUEST_URI'] ?? '/dashboard.php');
$uiLang = dbnToolsCurrentLanguage();
$tools = dbnToolsLaunchedTools($uiLang);
@@ -36,6 +33,17 @@ if ($dashAuthUser !== null) {
$dashEmail = strstr($e, '@', true) ?: $e;
}
$dashProfile = $dashIsSso ? dbnToolsMainUserProfile($dashUserId) : null;
$dashProfileNeedsPrompt = dbnToolsProfileNeedsPrompt($dashProfile);
$dashProfileValue = static function (string $key) use ($dashProfile): string {
return (string)($dashProfile[$key] ?? '');
};
$dashSeatLimit = match ($dashTier) {
'pro' => 3,
default => 1,
};
$dashTeamLabel = $dashSeatLimit > 1 ? '1 / ' . $dashSeatLimit . ' seats in use' : 'Single-user workspace';
// Next refill / billing date
$dashNextBilling = '';
$dashNextBillingKey = 'next_refill';
@@ -218,6 +226,7 @@ $langSuffix = $uiLang !== 'en' ? '?lang=' . urlencode($uiLang) : '';
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="assets/css/tools.css">
<link rel="stylesheet" href="assets/css/dbn-tools-redesign.css">
<link rel="stylesheet" href="assets/css/dashboard.css">
<style>
/* ── Dashboard enhancements ───────────────────────────── */
details.dash-mcp-details summary::-webkit-details-marker { display: none; }
@@ -258,29 +267,29 @@ window.DBN_TOOLS_LANG = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
?>
<!-- ── Account overview bar ─────────────────────────────────────── -->
<div style="background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:1.1rem 1.5rem; margin:0 0 1.25rem; display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap;">
<div style="display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; min-width:0;">
<div class="dash-overview-bar">
<div class="dash-overview-bar__left">
<span class="dash-tier-badge" style="background:<?= $tierLabel[1] ?>; color:<?= $tierLabel[2] ?>;"><?= htmlspecialchars($tierLabel[0]) ?></span>
<?php if (!empty($dashDetail['trial_active'])): ?>
<span class="dash-tier-badge" style="background:#fef3c7; color:#92400e;"><?= htmlspecialchars(sprintf($dl['trial_badge'], (int)$dashDetail['trial_days_remaining'])) ?></span>
<?php endif; ?>
<span style="font-size:1.08rem; font-weight:700; color:#00205B; white-space:nowrap;"><?= number_format($eff) ?> <?= $uiLang === 'no' ? 'kred.' : 'credits' ?></span>
<span style="color:#9ca3af; font-size:.92rem; white-space:nowrap;"><?= (int)$dashDetail['balance'] ?> <?= $uiLang === 'no' ? 'månedlige' : 'monthly' ?> · <?= (int)$dashDetail['bonus_balance'] ?> <?= $uiLang === 'no' ? 'forhåndsbetalt' : 'prepaid' ?></span>
<span class="dash-overview-bar__credits"><?= number_format($eff) ?> <?= $uiLang === 'no' ? 'kred.' : 'credits' ?></span>
<span class="dash-overview-bar__meta"><?= (int)$dashDetail['balance'] ?> <?= $uiLang === 'no' ? 'månedlige' : 'monthly' ?> · <?= (int)$dashDetail['bonus_balance'] ?> <?= $uiLang === 'no' ? 'forhåndsbetalt' : 'prepaid' ?></span>
<?php if ($dashNextBilling): ?>
<span style="color:#6b7280; font-size:.92rem; white-space:nowrap;"><?= htmlspecialchars($dl[$dashNextBillingKey]) ?>: <strong style="color:#374151;"><?= htmlspecialchars($dashNextBilling) ?></strong></span>
<span class="dash-overview-bar__meta"><?= htmlspecialchars($dl[$dashNextBillingKey]) ?>: <strong><?= htmlspecialchars($dashNextBilling) ?></strong></span>
<?php endif; ?>
<?php if ($dashEmail): ?>
<span style="color:#d1d5db; font-size:.92rem; white-space:nowrap; display:none;" class="dash-email-sep">·</span>
<span style="color:#9ca3af; font-size:.92rem; white-space:nowrap;"><?= htmlspecialchars($dl['signed_in_as']) ?>: <strong style="color:#374151;"><?= htmlspecialchars($dashEmail) ?></strong></span>
<span class="dash-overview-bar__meta dash-email-sep" style="display:none;">·</span>
<span class="dash-overview-bar__meta"><?= htmlspecialchars($dl['signed_in_as']) ?>: <strong><?= htmlspecialchars($dashEmail) ?></strong></span>
<?php endif; ?>
</div>
<div style="display:flex; align-items:center; gap:.5rem; flex-shrink:0; flex-wrap:wrap;">
<div class="dash-overview-bar__right">
<?php if ($isPaid): ?>
<a href="/billing.php" style="color:#6b7280; font-size:.92rem; text-decoration:none; border:1px solid #e5e7eb; padding:7px 14px; border-radius:7px; white-space:nowrap;"><?= htmlspecialchars($dl['manage_plan']) ?></a>
<a href="/billing.php" class="dash-overview-bar__action dash-overview-bar__action--manage"><?= htmlspecialchars($dl['manage_plan']) ?></a>
<?php else: ?>
<a href="/pricing.php" style="background:#00205B; color:#fff; font-size:.92rem; font-weight:700; text-decoration:none; padding:7px 14px; border-radius:7px; white-space:nowrap;"><?= htmlspecialchars($dl['upgrade_plan']) ?> →</a>
<a href="/pricing.php" class="dash-overview-bar__action dash-overview-bar__action--upgrade"><?= htmlspecialchars($dl['upgrade_plan']) ?> →</a>
<?php endif; ?>
<a href="/pricing.php#topup" style="background:#eff6ff; color:#1d4ed8; border:1px solid #bfdbfe; font-size:.92rem; font-weight:600; text-decoration:none; padding:7px 14px; border-radius:7px; white-space:nowrap;"><?= htmlspecialchars($dl['top_up']) ?> →</a>
<a href="/pricing.php#topup" class="dash-overview-bar__action dash-overview-bar__action--topup"><?= htmlspecialchars($dl['top_up']) ?> →</a>
</div>
</div>
@@ -323,6 +332,86 @@ window.DBN_TOOLS_LANG = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
</a>
<?php endif; ?>
</section>
<section id="account" class="dashboard-account-grid" aria-label="Account and profile">
<article class="dash-account-panel dash-account-panel--profile">
<div class="dash-account-panel__head">
<div>
<p class="dash-section-kicker">Account</p>
<h2>Profile details</h2>
</div>
<span class="dash-account-chip">Optional</span>
</div>
<form id="profileForm" class="dash-profile-form">
<label>
<span>Display name</span>
<input name="display_name" type="text" maxlength="100" autocomplete="name" value="<?= htmlspecialchars($dashProfileValue('display_name')) ?>">
</label>
<label>
<span>Phone</span>
<input name="phone" type="tel" maxlength="40" autocomplete="tel" value="<?= htmlspecialchars($dashProfileValue('phone')) ?>">
</label>
<label class="span-2">
<span>Address line 1</span>
<input name="address_line1" type="text" maxlength="180" autocomplete="address-line1" value="<?= htmlspecialchars($dashProfileValue('address_line1')) ?>">
</label>
<label class="span-2">
<span>Address line 2</span>
<input name="address_line2" type="text" maxlength="180" autocomplete="address-line2" value="<?= htmlspecialchars($dashProfileValue('address_line2')) ?>">
</label>
<label>
<span>Postal code</span>
<input name="postal_code" type="text" maxlength="32" autocomplete="postal-code" value="<?= htmlspecialchars($dashProfileValue('postal_code')) ?>">
</label>
<label>
<span>City</span>
<input name="city" type="text" maxlength="100" autocomplete="address-level2" value="<?= htmlspecialchars($dashProfileValue('city')) ?>">
</label>
<label>
<span>Region</span>
<input name="address_region" type="text" maxlength="100" autocomplete="address-level1" value="<?= htmlspecialchars($dashProfileValue('address_region')) ?>">
</label>
<label>
<span>Country</span>
<input name="country" type="text" maxlength="2" autocomplete="country" placeholder="NO" value="<?= htmlspecialchars($dashProfileValue('country')) ?>">
</label>
<input type="hidden" name="preferred_language" value="<?= htmlspecialchars($uiLang) ?>">
<div class="dash-profile-actions span-2">
<button type="submit" class="dash-profile-save">Save profile</button>
<p id="profileStatus" class="dash-profile-status" role="status" aria-live="polite"></p>
</div>
</form>
</article>
<article class="dash-account-panel">
<div class="dash-account-panel__head">
<div>
<p class="dash-section-kicker">Users</p>
<h2>Workspace access</h2>
</div>
<span class="dash-account-chip"><?= htmlspecialchars($tierLabel[0]) ?></span>
</div>
<div class="dash-account-list">
<div>
<span>Email</span>
<strong><?= htmlspecialchars((string)($dashAuthUser['email'] ?? '')) ?></strong>
</div>
<div>
<span>Seats</span>
<strong><?= htmlspecialchars($dashTeamLabel) ?></strong>
</div>
<div>
<span>Role</span>
<strong>Owner</strong>
</div>
<div>
<span>Plan storage</span>
<strong><?= $isPaid ? htmlspecialchars(round((int)$dashDetail['storage_quota_bytes'] / 1048576) . ' MB') : 'Upgrade for My Case storage' ?></strong>
</div>
</div>
<p class="dash-account-note">Team invitations and seat management will be added after this consolidation pass.</p>
</article>
</section>
<?php endif; ?>
<div class="disclaimer" role="note"><?= htmlspecialchars(dbnToolsT('disclaimer', $uiLang)) ?></div>
@@ -500,6 +589,47 @@ foreach ($tools as $slug => $item):
</div>
</div>
<?php if ($dashIsSso && $dashProfileNeedsPrompt): ?>
<div id="profilePromptModal" class="profile-prompt-backdrop" role="dialog" aria-modal="true" aria-labelledby="profilePromptTitle">
<div class="profile-prompt-card">
<p class="dash-section-kicker">Optional profile</p>
<h2 id="profilePromptTitle">Add contact details for your tools account</h2>
<p>These details help support and billing conversations. They are optional and never block tool access.</p>
<form id="profilePromptForm" class="dash-profile-form">
<label>
<span>Display name</span>
<input name="display_name" type="text" maxlength="100" autocomplete="name" value="<?= htmlspecialchars($dashProfileValue('display_name')) ?>">
</label>
<label>
<span>Phone</span>
<input name="phone" type="tel" maxlength="40" autocomplete="tel" value="<?= htmlspecialchars($dashProfileValue('phone')) ?>">
</label>
<label class="span-2">
<span>Address line 1</span>
<input name="address_line1" type="text" maxlength="180" autocomplete="address-line1" value="<?= htmlspecialchars($dashProfileValue('address_line1')) ?>">
</label>
<label>
<span>Postal code</span>
<input name="postal_code" type="text" maxlength="32" autocomplete="postal-code" value="<?= htmlspecialchars($dashProfileValue('postal_code')) ?>">
</label>
<label>
<span>City</span>
<input name="city" type="text" maxlength="100" autocomplete="address-level2" value="<?= htmlspecialchars($dashProfileValue('city')) ?>">
</label>
<label>
<span>Country</span>
<input name="country" type="text" maxlength="2" autocomplete="country" placeholder="NO" value="<?= htmlspecialchars($dashProfileValue('country')) ?>">
</label>
<input type="hidden" name="preferred_language" value="<?= htmlspecialchars($uiLang) ?>">
<div class="profile-prompt-actions span-2">
<button type="button" id="profilePromptSkip">Skip for now</button>
<button type="submit">Save details</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
</main>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
<script src="assets/js/tools.js" defer></script>
@@ -530,6 +660,69 @@ foreach ($tools as $slug => $item):
}
}());
/* Profile details */
(function () {
function payloadFromForm(form, dismiss) {
var data = {};
if (form) {
Array.prototype.forEach.call(form.elements, function (el) {
if (!el.name) return;
data[el.name] = el.value || '';
});
}
if (dismiss) data.dismiss_prompt = true;
return data;
}
function saveProfile(payload) {
return fetch('/api/profile.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload)
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok || !data.ok) {
throw new Error(data.error && data.error.message ? data.error.message : 'Could not save profile');
}
return data;
});
});
}
var profileForm = document.getElementById('profileForm');
var profileStatus = document.getElementById('profileStatus');
if (profileForm) {
profileForm.addEventListener('submit', function (e) {
e.preventDefault();
if (profileStatus) profileStatus.textContent = 'Saving...';
saveProfile(payloadFromForm(profileForm, true))
.then(function () { if (profileStatus) profileStatus.textContent = 'Saved'; })
.catch(function (err) { if (profileStatus) profileStatus.textContent = err.message; });
});
}
var promptModal = document.getElementById('profilePromptModal');
var promptForm = document.getElementById('profilePromptForm');
var promptSkip = document.getElementById('profilePromptSkip');
function closePrompt() {
if (promptModal) promptModal.hidden = true;
document.body.style.overflow = '';
}
if (promptModal) document.body.style.overflow = 'hidden';
if (promptForm) {
promptForm.addEventListener('submit', function (e) {
e.preventDefault();
saveProfile(payloadFromForm(promptForm, true)).then(closePrompt).catch(function (err) { alert(err.message); });
});
}
if (promptSkip) {
promptSkip.addEventListener('click', function () {
saveProfile(payloadFromForm(promptForm, true)).then(closePrompt).catch(closePrompt);
});
}
}());
/* ── Corpus summary ─────────────────────────────────────────────────── */
<?php if ($dashIsSso): ?>
(function () {