feat(auth): add login/logout, user identity, and soft auth gate

- api/logout.php: destroys session + clears cookie, redirects to /
- api/guest-session.php: sets guest flag, lets users explore without account
- layout.php: removes hard PHP redirect; authenticated users see email +
  "Logg ut" in topbar; guests see guest banner (sticky, dismissible) and
  auth gate modal (dismissible via localStorage) instead of redirect
- layout_footer.php: injects auth gate modal + JS for banner/modal dismiss
- layout_dashboard.php: adds username + "Logg ut" to dash-topbar
- index.php: adds "Utforsk uten konto" link under primary login CTA
- tools.css: .guest-banner, .auth-gate-*, .topbar-user, .dash-topbar__user

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 18:05:51 +02:00
parent b6212b8729
commit 33dc5406b2
7 changed files with 337 additions and 9 deletions
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
dbnToolsStartSession();
$_SESSION['dbn_tools_guest_ok'] = true;
$raw = trim((string)($_GET['return'] ?? '/dashboard.php'));
$return = (str_starts_with($raw, '/') && !str_starts_with($raw, '//') && !preg_match('/[\r\n]/', $raw))
? $raw
: '/dashboard.php';
header('Location: ' . $return);
exit;
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
dbnToolsStartSession();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', [
'expires' => time() - 3600,
'path' => $p['path'],
'domain' => $p['domain'] ?: '',
'secure' => $p['secure'],
'httponly' => true,
'samesite' => 'Lax',
]);
}
session_destroy();
header('Location: /');
exit;
+202
View File
@@ -8612,3 +8612,205 @@ body.lt-landing {
margin-top: 0;
scroll-margin-top: 70px;
}
/* ── Auth gate & user identity ────────────────────────────────────────────── */
/* Guest banner — sticky strip at the top of tool pages for unauthenticated users */
.guest-banner {
position: sticky;
top: 0;
z-index: 800;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 1.25rem;
background: #00205b;
color: #fff;
font-size: 0.84rem;
font-family: inherit;
line-height: 1.4;
}
.guest-banner__login {
color: #f4c542;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
}
.guest-banner__login:hover { text-decoration: underline; }
.guest-banner__close {
margin-left: auto;
background: none;
border: none;
color: rgba(255,255,255,0.65);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0.15rem 0.3rem;
border-radius: 4px;
transition: color 120ms;
}
.guest-banner__close:hover { color: #fff; }
/* Auth gate modal */
.auth-gate-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.58);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.auth-gate-backdrop[hidden] { display: none; }
.auth-gate-card {
background: #fff;
border-radius: 18px;
padding: 2.5rem 2.25rem 2rem;
max-width: 430px;
width: 100%;
text-align: center;
box-shadow: 0 24px 60px rgba(0,0,0,0.28);
}
.auth-gate-eyebrow {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: #ba0c2f;
margin: 0 0 0.65rem;
}
.auth-gate-title {
font-size: clamp(1.35rem, 3vw, 1.75rem);
font-weight: 800;
color: #16130f;
margin: 0 0 0.85rem;
line-height: 1.2;
}
.auth-gate-body {
font-size: 0.95rem;
color: rgba(22,19,15,0.65);
margin: 0 0 1.75rem;
line-height: 1.55;
}
.auth-gate-actions {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.auth-gate-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 46px;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
text-decoration: none;
transition: background 140ms, transform 110ms, box-shadow 140ms;
border: none;
font-family: inherit;
}
.auth-gate-btn--primary {
background: #00205b;
color: #fff;
box-shadow: 0 4px 14px rgba(0,32,91,0.30);
}
.auth-gate-btn--primary:hover {
background: #001840;
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(0,32,91,0.38);
}
.auth-gate-btn--ghost {
background: none;
color: rgba(22,19,15,0.50);
font-size: 0.85rem;
font-weight: 500;
height: auto;
padding: 0.25rem;
}
.auth-gate-btn--ghost:hover { color: rgba(22,19,15,0.75); }
.auth-gate-note {
margin: 1.1rem 0 0;
font-size: 0.78rem;
color: rgba(22,19,15,0.40);
}
/* Topbar user identity (authenticated users) */
.topbar-user {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
}
.topbar-user__name {
color: rgba(255,255,255,0.80);
font-weight: 500;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-user__logout {
color: rgba(255,255,255,0.65);
text-decoration: none;
font-weight: 500;
padding: 0.25rem 0.6rem;
border: 1px solid rgba(255,255,255,0.25);
border-radius: 6px;
transition: color 120ms, border-color 120ms, background 120ms;
white-space: nowrap;
}
.topbar-user__logout:hover {
color: #fff;
border-color: rgba(255,255,255,0.55);
background: rgba(255,255,255,0.08);
}
/* Dashboard topbar user identity */
.dash-topbar__user {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
font-size: 0.82rem;
}
.dash-topbar__username {
color: rgba(22,19,15,0.55);
font-weight: 500;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dash-topbar__logout {
color: rgba(22,19,15,0.50);
text-decoration: none;
font-weight: 500;
padding: 0.2rem 0.55rem;
border: 1px solid rgba(22,19,15,0.18);
border-radius: 6px;
transition: color 120ms, border-color 120ms;
white-space: nowrap;
}
.dash-topbar__logout:hover {
color: #ba0c2f;
border-color: rgba(186,12,47,0.40);
}
/* "Utforsk uten konto" link on landing page */
.lt-gate__explore-link {
margin: 0.75rem 0 0;
font-size: 0.84rem;
}
.lt-gate__explore-link a {
color: rgba(22,19,15,0.50);
text-decoration: none;
}
.lt-gate__explore-link a:hover {
color: rgba(22,19,15,0.80);
text-decoration: underline;
}
+36 -9
View File
@@ -2,11 +2,8 @@
declare(strict_types=1);
// Required vars: $toolName (string), $toolTitle (string), $toolKind (string), $toolBadge (string)
require_once __DIR__ . '/bootstrap.php';
if (!dbnToolsIsAuthenticated()) {
$return = urlencode($_SERVER['REQUEST_URI'] ?? '/');
header('Location: /?return=' . $return);
exit;
}
$layoutIsGuest = !dbnToolsIsAuthenticated();
$uiLang = dbnToolsCurrentLanguage();
$navItems = dbnToolsLaunchedTools($uiLang);
@@ -17,12 +14,23 @@ $toolKind = $toolMeta['sub'] ?? ($toolKind ?? '');
$toolBadge = $toolMeta['badge'] ?? ($toolBadge ?? '');
$langPath = strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?') ?: '/';
// Credit balance for free-tier users
// Credit balance — only meaningful for authenticated free-tier users
$layoutFreeTierBalance = -1;
if (dbnToolsIsFreeTier()) {
if (!$layoutIsGuest && dbnToolsIsFreeTier()) {
require_once __DIR__ . '/FreeTier.php';
$layoutFreeTierBalance = FreeTier::balance((int)$_SESSION['dbn_tools_sso_uid']);
}
// User identity for topbar (null for guests)
$layoutAuthUser = $layoutIsGuest ? null : dbnToolsAuthenticatedUser();
$layoutUserDisplay = '';
if ($layoutAuthUser !== null) {
$email = (string)($layoutAuthUser['email'] ?? '');
// Show only local part (before @) to keep topbar compact
$layoutUserDisplay = strstr($email, '@', true) ?: $email;
}
$layoutReturnUrl = urlencode($_SERVER['REQUEST_URI'] ?? '/');
?>
<!doctype html>
<html lang="<?= htmlspecialchars($uiLang) ?>">
@@ -35,9 +43,9 @@ if (dbnToolsIsFreeTier()) {
<?php endif; ?>
<link rel="stylesheet" href="assets/css/tools.css">
</head>
<body data-authenticated="true" data-active-tool="<?= htmlspecialchars($toolName) ?>">
<body data-authenticated="<?= $layoutIsGuest ? 'false' : 'true' ?>" data-active-tool="<?= htmlspecialchars($toolName) ?>">
<script>
window.DBN_TOOLS_AUTHENTICATED = true;
window.DBN_TOOLS_AUTHENTICATED = <?= $layoutIsGuest ? 'false' : 'true' ?>;
window.DBN_TOOLS_LANG = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
<?php if ($layoutFreeTierBalance >= 0): ?>
window.DBN_FREE_TIER_BALANCE = <?= $layoutFreeTierBalance ?>;
@@ -49,6 +57,15 @@ window.DBN_FREE_TIER_BALANCE = <?= $layoutFreeTierBalance ?>;
<button onclick="this.parentElement.remove()" aria-label="Dismiss" class="syttende-mai-close">✕</button>
</div>
<?php endif; ?>
<?php if ($layoutIsGuest): ?>
<div id="guestBanner" class="guest-banner" role="alert" aria-live="polite">
<span>Du er ikke innlogget — verktøyene krever konto for å fungere.</span>
<a href="/?return=<?= $layoutReturnUrl ?>" class="guest-banner__login">Logg inn</a>
<button id="guestBannerClose" class="guest-banner__close" aria-label="Lukk">✕</button>
</div>
<?php endif; ?>
<main id="appShell" class="app-shell">
<header class="topbar">
<div>
@@ -67,9 +84,19 @@ window.DBN_FREE_TIER_BALANCE = <?= $layoutFreeTierBalance ?>;
<a href="<?= htmlspecialchars($langPath . '?lang=' . $langCode) ?>" class="<?= $langCode === $uiLang ? 'is-active' : '' ?>"><?= htmlspecialchars(dbnToolsLanguageLabel($langCode)) ?></a>
<?php endforeach; ?>
</nav>
<?php if ($layoutIsGuest): ?>
<a href="/?return=<?= $layoutReturnUrl ?>" class="secondary-button" style="text-decoration:none;">Logg inn</a>
<?php else: ?>
<a href="/dashboard/" class="secondary-button" style="text-decoration:none;">📚 Min korpus</a>
<span id="healthPill" class="status-pill"><?= htmlspecialchars(dbnToolsT('session_active', $uiLang)) ?></span>
<button id="healthButton" class="secondary-button" type="button"><?= htmlspecialchars(dbnToolsT('health', $uiLang)) ?></button>
<span class="topbar-user">
<?php if ($layoutUserDisplay !== ''): ?>
<span class="topbar-user__name" title="<?= htmlspecialchars($layoutAuthUser['email'] ?? '') ?>"><?= htmlspecialchars($layoutUserDisplay) ?></span>
<?php endif; ?>
<a href="/api/logout.php" class="topbar-user__logout">Logg ut</a>
</span>
<?php endif; ?>
</div>
</header>
+13
View File
@@ -37,6 +37,13 @@ $dashboardTitle = $dashboardTitle ?? 'Dashboard';
$dashboardLead = $dashboardLead ?? '';
$langPath = strtok((string)($_SERVER['REQUEST_URI'] ?? '/dashboard/'), '?') ?: '/dashboard/';
$dashAuthUser = dbnToolsAuthenticatedUser();
$dashUserDisplay = '';
if ($dashAuthUser !== null) {
$email = (string)($dashAuthUser['email'] ?? '');
$dashUserDisplay = strstr($email, '@', true) ?: $email;
}
$dashboardNav = [
'index' => ['url' => '/dashboard/', 'label' => 'Oversikt', 'sub' => 'Overview'],
'documents' => ['url' => '/dashboard/documents.php', 'label' => 'Dokumenter', 'sub' => 'Documents'],
@@ -78,6 +85,12 @@ window.DBN_DASHBOARD = {
<nav class="dash-topbar__tools" aria-label="Tools">
<a href="/dashboard.php" class="dash-topbar__link">← Tilbake til verktøy</a>
</nav>
<div class="dash-topbar__user">
<?php if ($dashUserDisplay !== ''): ?>
<span class="dash-topbar__username" title="<?= htmlspecialchars($dashAuthUser['email'] ?? '') ?>"><?= htmlspecialchars($dashUserDisplay) ?></span>
<?php endif; ?>
<a href="/api/logout.php" class="dash-topbar__logout">Logg ut</a>
</div>
</header>
<div class="dash-layout">
+55
View File
@@ -47,5 +47,60 @@
</menu>
</form>
</dialog>
<?php if (!empty($layoutIsGuest)): ?>
<!-- Auth gate modal — shown to unauthenticated visitors unless they dismissed it -->
<div id="authGate" class="auth-gate-backdrop" hidden aria-modal="true" role="dialog" aria-labelledby="authGateTitle">
<div class="auth-gate-card">
<p class="auth-gate-eyebrow">Do Better Norge</p>
<h2 id="authGateTitle" class="auth-gate-title">Logg inn for å bruke verktøyene</h2>
<p class="auth-gate-body">Våre juridiske AI-verktøy krever en gratis konto. Registrer deg på sekunder med Google.</p>
<div class="auth-gate-actions">
<a id="authGateLogin" href="/?return=<?= htmlspecialchars($layoutReturnUrl ?? '') ?>" class="auth-gate-btn auth-gate-btn--primary">Logg inn / Registrer deg</a>
<button id="authGateDismiss" class="auth-gate-btn auth-gate-btn--ghost">Fortsett uten konto</button>
</div>
<p class="auth-gate-note">Gratis tilgang · Ingen kredittkort</p>
</div>
</div>
<script>
(function () {
'use strict';
var GATE_KEY = 'dbn-auth-gate-v1';
var BANNER_KEY = 'dbn-guest-banner-v1';
var gate = document.getElementById('authGate');
var banner = document.getElementById('guestBanner');
var bannerClose = document.getElementById('guestBannerClose');
// Show or hide the guest banner
if (banner) {
if (localStorage.getItem(BANNER_KEY) === 'closed') {
banner.hidden = true;
}
if (bannerClose) {
bannerClose.addEventListener('click', function () {
localStorage.setItem(BANNER_KEY, 'closed');
banner.hidden = true;
});
}
}
// Show auth gate modal unless already dismissed
if (gate && localStorage.getItem(GATE_KEY) !== 'dismissed') {
gate.hidden = false;
document.getElementById('authGateDismiss').addEventListener('click', function () {
localStorage.setItem(GATE_KEY, 'dismissed');
gate.hidden = true;
});
// Close on backdrop click
gate.addEventListener('click', function (e) {
if (e.target === gate) {
localStorage.setItem(GATE_KEY, 'dismissed');
gate.hidden = true;
}
});
}
}());
</script>
<?php endif; ?>
</body>
</html>
+1
View File
@@ -144,6 +144,7 @@ window.DBN_TOOLS_LANG = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
<h2 class="lt-gate__card-title lt-gate__card-title--large" id="ltGateTitle"><?= htmlspecialchars(dbnToolsT('member_card_title', $uiLang)) ?></h2>
<p class="lt-gate__card-note"><?= htmlspecialchars(dbnToolsT('member_card_note', $uiLang)) ?></p>
<a class="lt-access-btn" href="<?= htmlspecialchars($toolsLogin) ?>"><?= htmlspecialchars(dbnToolsT('primary_access', $uiLang)) ?></a>
<p class="lt-gate__explore-link"><a href="/api/guest-session.php?return=/dashboard.php">Utforsk uten konto &rarr;</a></p>
<details class="fallback-login">
<summary><?= htmlspecialchars(dbnToolsT('secondary_access', $uiLang)) ?></summary>
<form id="passcodeForm" class="passcode-form">