feat(dashboard): add corpus dashboard at /dashboard/
Full private corpus dashboard for tools.dobetternorge.no users — each SSO
account gets an auto-provisioned CaveauAI tenant (clients row, corpus) on
first visit. Includes upload (file/paste/URL), RAG chat with SSE streaming
and citation chips, document CRUD, FalkorDB graph relations tab, and
improved save-from-tool flow with tag/preview support.
- dashboard/{index,documents,document,upload,chat,settings}.php
- api/dashboard/{corpus-init,documents,upload,ingest-status,chat-stream,
save-from-tool,graph}.php
- includes/{CorpusProvision,layout_dashboard,layout_dashboard_footer}.php
- assets/css/dashboard.css assets/js/corpus-save.js (routing upgrade)
- includes/{bootstrap,layout}.php extended for dashboard provisioning
Migration 141 (clients.dbn_sso_uid + import_method enum) applied on chloe.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* CorpusProvision — idempotent per-SSO-user tenant provisioning for /dashboard/.
|
||||
*
|
||||
* The dashboard treats every SSO user as their own CaveauAI client tenant so
|
||||
* that the existing client_id-based row filtering is enough to keep one user's
|
||||
* documents from leaking to another. Provisioning is lazy: the first time the
|
||||
* SSO user hits the dashboard, we create:
|
||||
*
|
||||
* - one row in `clients` (slug `dbn-user-<sso_uid>`, plan `sandbox`,
|
||||
* dbn_sso_uid = sso_uid, fresh api_key hash)
|
||||
* - one row in `client_users` (role owner, random password hash — login is
|
||||
* always via SSO bridge, never password)
|
||||
* - one row in `client_corpora` (slug `default`, is_default=1)
|
||||
*
|
||||
* CaveauAI sessions (which already have client_id + client_user_id set on the
|
||||
* tools session) are returned as-is without touching the DB.
|
||||
*
|
||||
* Output shape:
|
||||
* [
|
||||
* 'client_id' => int,
|
||||
* 'client_user_id' => int,
|
||||
* 'corpus_id' => int,
|
||||
* 'created' => bool, // true the first time, false on every call after
|
||||
* ]
|
||||
*/
|
||||
final class CorpusProvision
|
||||
{
|
||||
public static function ensureForSsoUser(int $ssoUid, string $email, string $displayName): array
|
||||
{
|
||||
if ($ssoUid <= 0) {
|
||||
throw new DbnToolsHttpException('SSO user id is required.', 400, 'missing_sso_uid');
|
||||
}
|
||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new DbnToolsHttpException('A valid email is required.', 400, 'invalid_email');
|
||||
}
|
||||
|
||||
dbnToolsBootCaveau();
|
||||
$db = getDb();
|
||||
|
||||
$existing = self::lookupBySso($db, $ssoUid);
|
||||
if ($existing !== null) {
|
||||
$corpusId = self::ensureDefaultCorpus($db, $existing['client_id'], $email);
|
||||
return [
|
||||
'client_id' => $existing['client_id'],
|
||||
'client_user_id' => $existing['client_user_id'],
|
||||
'corpus_id' => $corpusId,
|
||||
'created' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$clientId = self::createClient($db, $ssoUid, $email, $displayName);
|
||||
$clientUserId = self::createOwnerUser($db, $clientId, $email, $displayName);
|
||||
$corpusId = self::ensureDefaultCorpus($db, $clientId, $email);
|
||||
self::subscribeIncludedPackages($db, $clientId);
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
$db->rollBack();
|
||||
throw new DbnToolsHttpException(
|
||||
'Could not provision dashboard tenant: ' . $e->getMessage(),
|
||||
500,
|
||||
'provision_failed'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'client_id' => $clientId,
|
||||
'client_user_id' => $clientUserId,
|
||||
'corpus_id' => $corpusId,
|
||||
'created' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public static function ensureForCaveauSession(int $clientId, string $email): array
|
||||
{
|
||||
if ($clientId <= 0) {
|
||||
throw new DbnToolsHttpException('Caveau client_id is required.', 400, 'missing_client_id');
|
||||
}
|
||||
dbnToolsBootCaveau();
|
||||
$db = getDb();
|
||||
$corpusId = self::ensureDefaultCorpus($db, $clientId, $email);
|
||||
return [
|
||||
'client_id' => $clientId,
|
||||
'client_user_id' => 0,
|
||||
'corpus_id' => $corpusId,
|
||||
'created' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private static function lookupBySso(PDO $db, int $ssoUid): ?array
|
||||
{
|
||||
$stmt = $db->prepare('SELECT id FROM clients WHERE dbn_sso_uid = ? LIMIT 1');
|
||||
$stmt->execute([$ssoUid]);
|
||||
$clientId = (int)($stmt->fetchColumn() ?: 0);
|
||||
if ($clientId === 0) {
|
||||
return null;
|
||||
}
|
||||
$stmt = $db->prepare(
|
||||
"SELECT id FROM client_users
|
||||
WHERE client_id = ? AND role = 'owner' AND is_active = 1
|
||||
ORDER BY id ASC LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$clientId]);
|
||||
$userId = (int)($stmt->fetchColumn() ?: 0);
|
||||
return ['client_id' => $clientId, 'client_user_id' => $userId];
|
||||
}
|
||||
|
||||
private static function createClient(PDO $db, int $ssoUid, string $email, string $displayName): int
|
||||
{
|
||||
require_once dbnToolsAiPortalRoot() . '/platform/includes/client_auth.php';
|
||||
$apiKey = generateApiKey();
|
||||
|
||||
$slug = self::uniqueSlug($db, 'dbn-user-' . $ssoUid);
|
||||
$name = $displayName !== '' ? $displayName : ('Dashboard ' . $email);
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO clients (
|
||||
dbn_sso_uid, slug, name, contact_email, country, timezone,
|
||||
plan, embedding_tier,
|
||||
api_key_hash, api_key_prefix,
|
||||
monthly_query_limit, monthly_document_limit, monthly_user_limit,
|
||||
is_active, plan_source, subscription_status
|
||||
) VALUES (
|
||||
?, ?, ?, ?, 'NO', 'Europe/Oslo',
|
||||
'sandbox', 'standard',
|
||||
?, ?,
|
||||
500, 50, 1,
|
||||
1, 'signup', 'none'
|
||||
)
|
||||
");
|
||||
$stmt->execute([
|
||||
$ssoUid,
|
||||
$slug,
|
||||
$name,
|
||||
$email,
|
||||
$apiKey['hash'],
|
||||
$apiKey['prefix'],
|
||||
]);
|
||||
return (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
private static function createOwnerUser(PDO $db, int $clientId, string $email, string $displayName): int
|
||||
{
|
||||
$stmt = $db->prepare('SELECT id, client_id FROM client_users WHERE email = ? LIMIT 1');
|
||||
$stmt->execute([$email]);
|
||||
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($existing && (int)$existing['client_id'] === $clientId) {
|
||||
return (int)$existing['id'];
|
||||
}
|
||||
if ($existing) {
|
||||
throw new RuntimeException("Email {$email} already belongs to another workspace.");
|
||||
}
|
||||
|
||||
$usernameBase = preg_replace('/[^a-z0-9._-]+/', '', strtolower(strstr($email, '@', true) ?: 'owner'));
|
||||
$usernameBase = $usernameBase !== '' ? $usernameBase : 'owner';
|
||||
$username = self::uniqueUsername($db, $usernameBase);
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO client_users
|
||||
(client_id, username, email, display_name, password_hash, role, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, 'owner', 1)
|
||||
");
|
||||
$stmt->execute([
|
||||
$clientId,
|
||||
$username,
|
||||
$email,
|
||||
$displayName !== '' ? $displayName : null,
|
||||
password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT),
|
||||
]);
|
||||
return (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
private static function ensureDefaultCorpus(PDO $db, int $clientId, string $email): int
|
||||
{
|
||||
$stmt = $db->prepare(
|
||||
'SELECT id FROM client_corpora
|
||||
WHERE client_id = ? AND is_default = 1
|
||||
ORDER BY id ASC LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$clientId]);
|
||||
$id = (int)($stmt->fetchColumn() ?: 0);
|
||||
if ($id > 0) {
|
||||
return $id;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO client_corpora (client_id, name, slug, description, is_default)
|
||||
VALUES (?, ?, 'default', ?, 1)
|
||||
");
|
||||
$stmt->execute([
|
||||
$clientId,
|
||||
'Min korpus',
|
||||
'Default personal corpus for ' . $email,
|
||||
]);
|
||||
return (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
private static function subscribeIncludedPackages(PDO $db, int $clientId): void
|
||||
{
|
||||
$packageSlug = dbnToolsRequiredPackageSlug();
|
||||
$stmt = $db->prepare('SELECT id FROM corpus_packages WHERE slug = ? AND is_active = 1 LIMIT 1');
|
||||
$stmt->execute([$packageSlug]);
|
||||
$packageId = (int)($stmt->fetchColumn() ?: 0);
|
||||
if ($packageId === 0) {
|
||||
return;
|
||||
}
|
||||
$db->prepare(
|
||||
"INSERT IGNORE INTO client_corpus_subscriptions
|
||||
(client_id, package_id, is_active, source, subscribed_at)
|
||||
VALUES (?, ?, 1, 'dbn_dashboard', NOW())"
|
||||
)->execute([$clientId, $packageId]);
|
||||
}
|
||||
|
||||
private static function uniqueSlug(PDO $db, string $base): string
|
||||
{
|
||||
$base = preg_replace('/[^a-z0-9-]+/', '-', strtolower($base)) ?: 'dbn-user';
|
||||
$base = trim($base, '-');
|
||||
$stmt = $db->prepare('SELECT id FROM clients WHERE slug = ? LIMIT 1');
|
||||
$slug = $base;
|
||||
$suffix = 2;
|
||||
while (true) {
|
||||
$stmt->execute([$slug]);
|
||||
if (!$stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
return $slug;
|
||||
}
|
||||
$slug = $base . '-' . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
}
|
||||
|
||||
private static function uniqueUsername(PDO $db, string $base): string
|
||||
{
|
||||
$stmt = $db->prepare('SELECT id FROM client_users WHERE username = ? LIMIT 1');
|
||||
$username = $base;
|
||||
$suffix = 2;
|
||||
while (true) {
|
||||
$stmt->execute([$username]);
|
||||
if (!$stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
return $username;
|
||||
}
|
||||
$username = $base . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -365,6 +365,50 @@ function dbnToolsBootCaveau(): void
|
||||
$booted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve (or lazily provision) the dashboard tenant for the current session.
|
||||
*
|
||||
* - CaveauAI sessions return the existing client_id/client_user_id and ensure a
|
||||
* default corpus exists.
|
||||
* - SSO sessions promote the user to their own CaveauAI client tenant on first
|
||||
* call (via CorpusProvision), then cache the result on the session.
|
||||
*
|
||||
* Returns ['client_id', 'client_user_id', 'corpus_id', 'created'].
|
||||
* Throws DbnToolsHttpException on auth/provisioning failure.
|
||||
*/
|
||||
function dbnToolsEnsureDashboardTenant(): array
|
||||
{
|
||||
if (!dbnToolsIsAuthenticated()) {
|
||||
throw new DbnToolsHttpException('Dashboard requires an authenticated session.', 401, 'session_required');
|
||||
}
|
||||
|
||||
$cached = $_SESSION['dbn_tools_dashboard_tenant'] ?? null;
|
||||
if (is_array($cached) && !empty($cached['client_id']) && !empty($cached['corpus_id'])) {
|
||||
return $cached + ['created' => false];
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/CorpusProvision.php';
|
||||
|
||||
if (dbnToolsIsFreeTier()) {
|
||||
$ssoUid = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
|
||||
$email = (string)($_SESSION['dbn_tools_sso_email'] ?? $_SESSION['dbn_tools_user_email'] ?? '');
|
||||
$displayName = (string)($_SESSION['dbn_tools_sso_name'] ?? $_SESSION['dbn_tools_user_name'] ?? '');
|
||||
$tenant = CorpusProvision::ensureForSsoUser($ssoUid, $email, $displayName);
|
||||
} else {
|
||||
$clientId = (int)($_SESSION['dbn_tools_client_id'] ?? 0);
|
||||
$email = (string)($_SESSION['dbn_tools_user_email'] ?? '');
|
||||
$tenant = CorpusProvision::ensureForCaveauSession($clientId, $email);
|
||||
$tenant['client_user_id'] = (int)($_SESSION['dbn_tools_user_id'] ?? $tenant['client_user_id']);
|
||||
}
|
||||
|
||||
$_SESSION['dbn_tools_dashboard_tenant'] = [
|
||||
'client_id' => (int)$tenant['client_id'],
|
||||
'client_user_id' => (int)$tenant['client_user_id'],
|
||||
'corpus_id' => (int)$tenant['corpus_id'],
|
||||
];
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
function dbnToolsDb(): PDO
|
||||
{
|
||||
dbnToolsBootCaveau();
|
||||
|
||||
@@ -67,6 +67,7 @@ window.DBN_FREE_TIER_BALANCE = <?= $layoutFreeTierBalance ?>;
|
||||
<a href="<?= htmlspecialchars($langPath . '?lang=' . $langCode) ?>" class="<?= $langCode === $uiLang ? 'is-active' : '' ?>"><?= htmlspecialchars(dbnToolsLanguageLabel($langCode)) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Dashboard chrome (minimal). Used by /dashboard/* pages.
|
||||
*
|
||||
* Page contract:
|
||||
* $dashboardPage string — slug for active-state ('index'|'documents'|'document'|'upload'|'chat'|'settings')
|
||||
* $dashboardTitle string — H1 for the content area
|
||||
* $dashboardLead string? — optional sub-title sentence
|
||||
* $extraScripts string[]?— optional extra script srcs (defer-loaded)
|
||||
*
|
||||
* Lazy-provisions the tenant on first hit; exposes ids to JS as window.DBN_DASHBOARD.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
if (!dbnToolsIsAuthenticated()) {
|
||||
$return = urlencode($_SERVER['REQUEST_URI'] ?? '/dashboard/');
|
||||
header('Location: /?return=' . $return);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$dashboardTenant = dbnToolsEnsureDashboardTenant();
|
||||
} catch (DbnToolsHttpException $e) {
|
||||
http_response_code($e->status);
|
||||
echo '<!doctype html><meta charset="utf-8"><title>Dashboard unavailable</title>'
|
||||
. '<p style="font-family:sans-serif;max-width:540px;margin:4rem auto;">'
|
||||
. htmlspecialchars($e->getMessage())
|
||||
. ' <a href="/dashboard/">Try again</a></p>';
|
||||
exit;
|
||||
}
|
||||
|
||||
$uiLang = dbnToolsCurrentLanguage();
|
||||
$dashboardPage = $dashboardPage ?? 'index';
|
||||
$dashboardTitle = $dashboardTitle ?? 'Dashboard';
|
||||
$dashboardLead = $dashboardLead ?? '';
|
||||
$langPath = strtok((string)($_SERVER['REQUEST_URI'] ?? '/dashboard/'), '?') ?: '/dashboard/';
|
||||
|
||||
$dashboardNav = [
|
||||
'index' => ['url' => '/dashboard/', 'label' => 'Oversikt', 'sub' => 'Overview'],
|
||||
'documents' => ['url' => '/dashboard/documents.php', 'label' => 'Dokumenter', 'sub' => 'Documents'],
|
||||
'upload' => ['url' => '/dashboard/upload.php', 'label' => 'Last opp', 'sub' => 'Upload'],
|
||||
'chat' => ['url' => '/dashboard/chat.php', 'label' => 'Spør', 'sub' => 'Ask'],
|
||||
'settings' => ['url' => '/dashboard/settings.php', 'label' => 'Innstillinger', 'sub' => 'Settings'],
|
||||
];
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?= htmlspecialchars($uiLang) ?>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= htmlspecialchars($dashboardTitle) ?> · Min korpus · Do Better Norge</title>
|
||||
<link rel="stylesheet" href="/assets/css/tools.css">
|
||||
<link rel="stylesheet" href="/assets/css/dashboard.css">
|
||||
</head>
|
||||
<body data-authenticated="true" data-dashboard-page="<?= htmlspecialchars($dashboardPage) ?>">
|
||||
<script>
|
||||
window.DBN_TOOLS_AUTHENTICATED = true;
|
||||
window.DBN_TOOLS_LANG = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
|
||||
window.DBN_DASHBOARD = {
|
||||
clientId: <?= (int)$dashboardTenant['client_id'] ?>,
|
||||
clientUserId: <?= (int)$dashboardTenant['client_user_id'] ?>,
|
||||
corpusId: <?= (int)$dashboardTenant['corpus_id'] ?>,
|
||||
apiBase: '/api/dashboard'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="dash-shell">
|
||||
<header class="dash-topbar" role="banner">
|
||||
<a class="dash-brand" href="/dashboard/">
|
||||
<span class="dash-brand__mark">⚖</span>
|
||||
<span class="dash-brand__text">
|
||||
<strong>Min korpus</strong>
|
||||
<small>Do Better Norge</small>
|
||||
</span>
|
||||
</a>
|
||||
<nav class="dash-topbar__tools" aria-label="Tools">
|
||||
<a href="/dashboard.php" class="dash-topbar__link">← Tilbake til verktøy</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="dash-layout">
|
||||
<nav class="dash-sidebar" aria-label="Dashboard sections">
|
||||
<?php foreach ($dashboardNav as $slug => $item): ?>
|
||||
<a href="<?= htmlspecialchars($item['url']) ?>"
|
||||
class="dash-sidebar__item<?= $slug === $dashboardPage ? ' is-active' : '' ?>"
|
||||
<?= $slug === $dashboardPage ? 'aria-current="page"' : '' ?>>
|
||||
<strong><?= htmlspecialchars($item['label']) ?></strong>
|
||||
<small><?= htmlspecialchars($item['sub']) ?></small>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
|
||||
<main class="dash-main" id="dashMain">
|
||||
<header class="dash-main__head">
|
||||
<h1><?= htmlspecialchars($dashboardTitle) ?></h1>
|
||||
<?php if ($dashboardLead !== ''): ?>
|
||||
<p class="dash-main__lead"><?= htmlspecialchars($dashboardLead) ?></p>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
<div class="dash-main__body">
|
||||
@@ -0,0 +1,31 @@
|
||||
</div><!-- /dash-main__body -->
|
||||
</main><!-- /dash-main -->
|
||||
</div><!-- /dash-layout -->
|
||||
</div><!-- /dash-shell -->
|
||||
|
||||
<script src="/assets/js/tools.js" defer></script>
|
||||
<?php if (!empty($extraScripts) && is_array($extraScripts)): foreach ($extraScripts as $extraScript): ?>
|
||||
<script src="<?= htmlspecialchars((string)$extraScript) ?>" defer></script>
|
||||
<?php endforeach; endif; ?>
|
||||
<script src="/assets/js/corpus-save.js" defer></script>
|
||||
|
||||
<dialog id="save-corpus-dialog" class="save-corpus-dialog">
|
||||
<form method="dialog" id="save-corpus-form">
|
||||
<h3>Save to corpus</h3>
|
||||
<p class="save-corpus-hint">This will be indexed and searchable in your private corpus.</p>
|
||||
<label>
|
||||
<span>Title <span aria-hidden="true">*</span></span>
|
||||
<input id="save-corpus-title" type="text" required autocomplete="off">
|
||||
</label>
|
||||
<label>
|
||||
<span>Tags <span class="save-corpus-optional">(comma-separated)</span></span>
|
||||
<input id="save-corpus-tags" type="text" placeholder="e.g. barnevern, 2024, kjennelse">
|
||||
</label>
|
||||
<menu>
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
<button type="button" id="save-corpus-cancel">Cancel</button>
|
||||
</menu>
|
||||
</form>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user