Implementer brukeradministrasjon i admin (/admin/users)

- Backend: users_admin.rs med liste, toggle aktiv/deaktiv, AI-budsjett
- Frontend: brukeroversikt med roller, budsjett, siste aktivitet, filter
- API: fetchUsersOverview, toggleUser, updateUserBudget
This commit is contained in:
vegard 2026-03-19 19:19:10 +00:00
parent e1f45ae8a8
commit 63b188641e
4 changed files with 748 additions and 0 deletions

View file

@ -1625,6 +1625,81 @@ export function toggleAgent(
return post(accessToken, '/admin/agents/toggle', { node_id: nodeId });
}
// =========================================================================
// Brukeradministrasjon (oppgave 064)
// =========================================================================
export interface UserRole {
collection_id: string;
collection_title: string;
role: string;
}
export interface UserAiBudget {
monthly_limit_usd: number;
}
export interface UserInfo {
node_id: string;
title: string;
email: string | null;
visibility: string;
created_at: string;
metadata: Record<string, unknown>;
is_active: boolean;
roles: UserRole[];
ai_budget: UserAiBudget | null;
ai_usage_this_month: number;
last_activity: string | null;
}
export interface UsersOverviewResponse {
users: UserInfo[];
total_count: number;
}
export interface ToggleUserResponse {
node_id: string;
is_active: boolean;
}
export interface UpdateBudgetResponse {
node_id: string;
ai_budget: UserAiBudget | null;
}
/** Hent brukeroversikt med roller, AI-budsjett og aktivitet. */
export async function fetchUsersOverview(accessToken: string): Promise<UsersOverviewResponse> {
const res = await fetch(`${BASE_URL}/admin/users`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) {
const body = await res.text();
throw new Error(`users overview failed (${res.status}): ${body}`);
}
return res.json();
}
/** Aktiver/deaktiver en bruker (toggle visibility). */
export function toggleUser(
accessToken: string,
nodeId: string
): Promise<ToggleUserResponse> {
return post(accessToken, '/admin/users/toggle', { node_id: nodeId });
}
/** Oppdater AI-budsjett for en bruker. null fjerner budsjettet. */
export function updateUserBudget(
accessToken: string,
nodeId: string,
monthlyLimitUsd: number | null
): Promise<UpdateBudgetResponse> {
return post(accessToken, '/admin/users/budget', {
node_id: nodeId,
monthly_limit_usd: monthlyLimitUsd
});
}
export async function setMixerRole(
accessToken: string,
roomId: string,

View file

@ -0,0 +1,351 @@
<script lang="ts">
/**
* Admin — Brukeradministrasjon (oppgave 064)
*
* Viser person-noder med roller, AI-budsjett, siste aktivitet.
* Støtter deaktivering/aktivering og oppdatering av AI-budsjett.
*/
import { page } from '$app/stores';
import {
fetchUsersOverview,
toggleUser,
updateUserBudget,
type UsersOverviewResponse,
type UserInfo
} from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
let data = $state<UsersOverviewResponse | null>(null);
let error = $state<string | null>(null);
let actionLoading = $state<string | null>(null);
// Budsjett-redigering
let editingBudget = $state<string | null>(null);
let budgetInput = $state('');
// Filter
let filter = $state('');
const filteredUsers = $derived(
data?.users.filter((u) => {
if (!filter) return true;
const q = filter.toLowerCase();
return (
u.title.toLowerCase().includes(q) ||
(u.email?.toLowerCase().includes(q) ?? false) ||
u.roles.some((r) => r.collection_title.toLowerCase().includes(q))
);
}) ?? []
);
// Poll hvert 10. sekund
$effect(() => {
if (!accessToken) return;
loadData();
const interval = setInterval(loadData, 10000);
return () => clearInterval(interval);
});
async function loadData() {
if (!accessToken) return;
try {
data = await fetchUsersOverview(accessToken);
error = null;
} catch (e) {
error = String(e);
}
}
async function handleToggle(nodeId: string, title: string, isActive: boolean) {
if (!accessToken || actionLoading) return;
const action = isActive ? 'deaktivere' : 'aktivere';
if (!confirm(`Vil du ${action} brukeren «${title}»?`)) return;
actionLoading = nodeId;
try {
await toggleUser(accessToken, nodeId);
await loadData();
} catch (e) {
error = String(e);
} finally {
actionLoading = null;
}
}
function startEditBudget(user: UserInfo) {
editingBudget = user.node_id;
budgetInput = user.ai_budget?.monthly_limit_usd?.toString() ?? '';
}
async function saveBudget(nodeId: string) {
if (!accessToken || actionLoading) return;
actionLoading = nodeId;
try {
const limit = budgetInput.trim() === '' ? null : parseFloat(budgetInput);
if (limit !== null && (isNaN(limit) || limit < 0)) {
error = 'Ugyldig budsjettverdi';
return;
}
await updateUserBudget(accessToken, nodeId, limit);
editingBudget = null;
budgetInput = '';
await loadData();
} catch (e) {
error = String(e);
} finally {
actionLoading = null;
}
}
function cancelEditBudget() {
editingBudget = null;
budgetInput = '';
}
function formatTime(iso: string | null): string {
if (!iso) return '-';
return new Date(iso).toLocaleString('nb-NO', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function timeAgo(iso: string | null): string {
if (!iso) return 'Aldri';
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Akkurat ';
if (minutes < 60) return `${minutes}m siden`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}t siden`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d siden`;
return formatTime(iso);
}
function roleLabel(role: string): string {
switch (role) {
case 'owner':
return 'Eier';
case 'admin':
return 'Admin';
case 'member_of':
return 'Medlem';
default:
return role;
}
}
function roleColor(role: string): string {
switch (role) {
case 'owner':
return 'bg-purple-100 text-purple-700';
case 'admin':
return 'bg-blue-100 text-blue-700';
case 'member_of':
return 'bg-gray-100 text-gray-700';
default:
return 'bg-gray-100 text-gray-600';
}
}
function budgetStatus(user: UserInfo): string {
if (!user.ai_budget) return '';
const pct = (user.ai_usage_this_month / user.ai_budget.monthly_limit_usd) * 100;
if (pct >= 100) return 'bg-red-100 text-red-700';
if (pct >= 80) return 'bg-amber-100 text-amber-700';
return 'bg-green-100 text-green-700';
}
</script>
<div class="min-h-screen bg-gray-50">
<header class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/admin" class="text-sm text-gray-500 hover:text-gray-700">Admin</a>
<span class="text-gray-300">/</span>
<h1 class="text-lg font-semibold text-gray-900">Brukere</h1>
</div>
{#if data}
<span class="text-sm text-gray-400">{data.total_count} brukere</span>
{/if}
</div>
</header>
<main class="mx-auto max-w-5xl px-4 py-6">
{#if !accessToken}
<p class="text-sm text-gray-400">Logg inn for tilgang.</p>
{:else if !data}
<p class="text-sm text-gray-400">Laster brukere...</p>
{:else}
<!-- Feilmelding -->
{#if error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{error}
<button onclick={() => (error = null)} class="ml-2 underline">Lukk</button>
</div>
{/if}
<!-- Søkefilter -->
<div class="mb-4">
<input
type="text"
placeholder="Filtrer brukere..."
bind:value={filter}
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm focus:border-blue-300 focus:outline-none focus:ring-1 focus:ring-blue-300 sm:w-64"
/>
</div>
<!-- Brukerliste -->
<section class="space-y-3">
{#each filteredUsers as user (user.node_id)}
<div
class="rounded-lg border bg-white p-4 shadow-sm {user.is_active ? 'border-gray-200' : 'border-red-200 bg-red-50/30'}"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<!-- Brukerinfo -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{user.title}</span>
{#if user.is_active}
<span
class="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700"
>
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
Aktiv
</span>
{:else}
<span
class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700"
>
<span class="h-1.5 w-1.5 rounded-full bg-red-500"></span>
Deaktivert
</span>
{/if}
</div>
<div class="mt-1 flex flex-wrap gap-3 text-xs text-gray-500">
{#if user.email}
<span>{user.email}</span>
{/if}
<span>Opprettet: {formatTime(user.created_at)}</span>
<span>Siste aktivitet: {timeAgo(user.last_activity)}</span>
</div>
<div
class="mt-1 font-mono text-xs text-gray-400"
title={user.node_id}
>
{user.node_id}
</div>
</div>
<!-- Aktiver/deaktiver -->
<button
onclick={() => handleToggle(user.node_id, user.title, user.is_active)}
disabled={actionLoading === user.node_id}
class="shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 {user.is_active
? 'bg-red-50 text-red-700 hover:bg-red-100'
: 'bg-green-50 text-green-700 hover:bg-green-100'}"
>
{user.is_active ? 'Deaktiver' : 'Aktiver'}
</button>
</div>
<!-- Roller og budsjett -->
<div class="mt-3 flex flex-wrap gap-4">
<!-- Roller -->
<div class="flex-1">
<div class="mb-1 text-xs font-medium text-gray-500">Roller</div>
{#if user.roles.length > 0}
<div class="flex flex-wrap gap-1">
{#each user.roles as role}
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium {roleColor(role.role)}"
>
{roleLabel(role.role)} i {role.collection_title}
</span>
{/each}
</div>
{:else}
<span class="text-xs text-gray-400">Ingen roller</span>
{/if}
</div>
<!-- AI-budsjett -->
<div class="min-w-[180px]">
<div class="mb-1 text-xs font-medium text-gray-500">AI-budsjett</div>
{#if editingBudget === user.node_id}
<div class="flex items-center gap-1">
<span class="text-xs text-gray-500">$</span>
<input
type="number"
step="1"
min="0"
bind:value={budgetInput}
placeholder="Ingen grense"
class="w-20 rounded border border-gray-300 px-2 py-1 text-xs focus:border-blue-300 focus:outline-none"
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Enter') saveBudget(user.node_id);
if (e.key === 'Escape') cancelEditBudget();
}}
/>
<span class="text-xs text-gray-400">/mnd</span>
<button
onclick={() => saveBudget(user.node_id)}
disabled={actionLoading === user.node_id}
class="rounded bg-blue-50 px-2 py-1 text-xs text-blue-700 hover:bg-blue-100 disabled:opacity-50"
>
Lagre
</button>
<button
onclick={cancelEditBudget}
class="rounded px-2 py-1 text-xs text-gray-500 hover:bg-gray-100"
>
Avbryt
</button>
</div>
{:else}
<div class="flex items-center gap-2">
{#if user.ai_budget}
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {budgetStatus(user)}"
>
${user.ai_usage_this_month.toFixed(2)} / ${user.ai_budget.monthly_limit_usd.toFixed(0)}
</span>
{:else}
<span class="text-xs text-gray-400">
{#if user.ai_usage_this_month > 0}
${user.ai_usage_this_month.toFixed(2)} (ingen grense)
{:else}
Ingen grense
{/if}
</span>
{/if}
<button
onclick={() => startEditBudget(user)}
class="rounded px-1.5 py-0.5 text-xs text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
Endre
</button>
</div>
{/if}
</div>
</div>
</div>
{:else}
<div
class="rounded-lg border border-gray-200 bg-white p-6 text-center text-sm text-gray-400"
>
{#if filter}
Ingen brukere matcher filteret.
{:else}
Ingen brukere funnet.
{/if}
</div>
{/each}
</section>
{/if}
</main>
</div>

View file

@ -2,6 +2,7 @@ pub mod agent;
mod agents_admin;
pub mod ai_admin;
mod api_keys_admin;
mod users_admin;
pub mod crypto;
pub mod ai_budget;
pub mod ai_edges;
@ -321,6 +322,10 @@ async fn main() {
// Agent-oversikt (oppgave 063)
.route("/admin/agents", get(agents_admin::agents_overview))
.route("/admin/agents/toggle", post(agents_admin::toggle_agent))
// Brukeradministrasjon (oppgave 064)
.route("/admin/users", get(users_admin::users_overview))
.route("/admin/users/toggle", post(users_admin::toggle_user))
.route("/admin/users/budget", post(users_admin::update_budget))
// API-nøkler (oppgave 060)
.route("/admin/api-keys", get(api_keys_admin::list_keys))
.route("/admin/api-keys/create", post(api_keys_admin::create_key))

View file

@ -0,0 +1,317 @@
// Admin-API for brukeradministrasjon (oppgave 064)
//
// Viser person-noder med roller, AI-budsjett, siste aktivitet.
// Støtter deaktivering/aktivering og oppdatering av AI-budsjett.
use axum::{extract::State, http::StatusCode, Json};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::AdminUser;
use crate::AppState;
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: msg.to_string() }))
}
// =============================================================================
// GET /admin/users — brukeroversikt
// =============================================================================
#[derive(Serialize)]
pub struct UserInfo {
pub node_id: Uuid,
pub title: String,
pub email: Option<String>,
pub visibility: String,
pub created_at: DateTime<Utc>,
pub metadata: serde_json::Value,
pub is_active: bool,
pub roles: Vec<UserRole>,
pub ai_budget: Option<AiBudget>,
pub ai_usage_this_month: f64,
pub last_activity: Option<DateTime<Utc>>,
}
#[derive(Serialize)]
pub struct UserRole {
pub collection_id: Uuid,
pub collection_title: String,
pub role: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct AiBudget {
pub monthly_limit_usd: f64,
}
#[derive(Serialize)]
pub struct UsersOverviewResponse {
pub users: Vec<UserInfo>,
pub total_count: i64,
}
pub async fn users_overview(
State(state): State<AppState>,
_admin: AdminUser,
) -> Result<Json<UsersOverviewResponse>, (StatusCode, Json<ErrorResponse>)> {
// Hent alle person-noder med auth-identitet
#[derive(sqlx::FromRow)]
struct UserRow {
id: Uuid,
title: Option<String>,
visibility: String,
metadata: serde_json::Value,
created_at: DateTime<Utc>,
email: Option<String>,
}
let user_rows = sqlx::query_as::<_, UserRow>(
r#"SELECT n.id, n.title, n.visibility::text as visibility,
n.metadata, n.created_at,
ai.email
FROM nodes n
LEFT JOIN auth_identities ai ON ai.node_id = n.id
WHERE n.node_kind = 'person'
ORDER BY n.title NULLS LAST, n.created_at"#,
)
.fetch_all(&state.db)
.await
.map_err(|e| internal_error(&format!("Feil ved henting av brukere: {e}")))?;
let total_count = user_rows.len() as i64;
let mut users = Vec::new();
for row in user_rows {
// Roller: edges fra person → collection med type owner/admin/member_of
#[derive(sqlx::FromRow)]
struct RoleRow {
collection_id: Uuid,
collection_title: Option<String>,
edge_type: String,
}
let roles = sqlx::query_as::<_, RoleRow>(
r#"SELECT e.target_id as collection_id,
n.title as collection_title,
e.edge_type
FROM edges e
JOIN nodes n ON n.id = e.target_id AND n.node_kind = 'collection'
WHERE e.source_id = $1
AND e.edge_type IN ('owner', 'admin', 'member_of')
ORDER BY e.edge_type, n.title"#,
)
.bind(row.id)
.fetch_all(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil (roller): {e}")))?;
// AI-budsjett fra metadata
let ai_budget = row.metadata
.get("ai_budget")
.and_then(|b| serde_json::from_value::<AiBudget>(b.clone()).ok());
// AI-forbruk denne måneden
let (total_prompt, total_completion): (i64, i64) = sqlx::query_as::<_, (i64, i64)>(
r#"SELECT
COALESCE(SUM(prompt_tokens)::BIGINT, 0),
COALESCE(SUM(completion_tokens)::BIGINT, 0)
FROM ai_usage_log
WHERE requested_by = $1
AND created_at >= date_trunc('month', now())"#,
)
.bind(row.id)
.fetch_one(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil (forbruk): {e}")))?;
let ai_usage_this_month = crate::ai_budget::estimate_cost_usd(total_prompt, total_completion);
// Siste aktivitet: nyeste created_at fra nodes opprettet av bruker
let last_activity: Option<DateTime<Utc>> = sqlx::query_scalar(
r#"SELECT MAX(created_at) FROM nodes WHERE created_by = $1"#,
)
.bind(row.id)
.fetch_one(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil (aktivitet): {e}")))?;
// is_active: person med visibility != 'hidden' anses som aktiv
let is_active = row.visibility != "hidden";
users.push(UserInfo {
node_id: row.id,
title: row.title.unwrap_or_else(|| "Uten navn".to_string()),
email: row.email,
visibility: row.visibility,
created_at: row.created_at,
metadata: row.metadata,
is_active,
roles: roles.into_iter().map(|r| UserRole {
collection_id: r.collection_id,
collection_title: r.collection_title.unwrap_or_else(|| "Ukjent".to_string()),
role: r.edge_type,
}).collect(),
ai_budget,
ai_usage_this_month,
last_activity,
});
}
Ok(Json(UsersOverviewResponse { users, total_count }))
}
// =============================================================================
// POST /admin/users/toggle — aktiver/deaktiver bruker
// =============================================================================
#[derive(Deserialize)]
pub struct ToggleUserRequest {
pub node_id: Uuid,
}
#[derive(Serialize)]
pub struct ToggleUserResponse {
pub node_id: Uuid,
pub is_active: bool,
}
pub async fn toggle_user(
State(state): State<AppState>,
_admin: AdminUser,
Json(req): Json<ToggleUserRequest>,
) -> Result<Json<ToggleUserResponse>, (StatusCode, Json<ErrorResponse>)> {
// Sjekk at noden er en person
let node_kind: Option<String> = sqlx::query_scalar(
"SELECT node_kind FROM nodes WHERE id = $1",
)
.bind(req.node_id)
.fetch_optional(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
match node_kind.as_deref() {
Some("person") => {}
Some(_) => return Err((StatusCode::BAD_REQUEST, Json(ErrorResponse {
error: "Noden er ikke en person".to_string(),
}))),
None => return Err((StatusCode::NOT_FOUND, Json(ErrorResponse {
error: "Bruker finnes ikke".to_string(),
}))),
}
// Toggle: hidden ↔ discoverable
let current_visibility: String = sqlx::query_scalar(
"SELECT visibility::text FROM nodes WHERE id = $1",
)
.bind(req.node_id)
.fetch_one(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
let new_visibility = if current_visibility == "hidden" { "discoverable" } else { "hidden" };
sqlx::query(
"UPDATE nodes SET visibility = $1::visibility WHERE id = $2",
)
.bind(new_visibility)
.bind(req.node_id)
.execute(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
let is_active = new_visibility != "hidden";
tracing::info!(user_node_id = %req.node_id, is_active, "Bruker-status endret");
Ok(Json(ToggleUserResponse {
node_id: req.node_id,
is_active,
}))
}
// =============================================================================
// POST /admin/users/budget — oppdater AI-budsjett
// =============================================================================
#[derive(Deserialize)]
pub struct UpdateBudgetRequest {
pub node_id: Uuid,
pub monthly_limit_usd: Option<f64>,
}
#[derive(Serialize)]
pub struct UpdateBudgetResponse {
pub node_id: Uuid,
pub ai_budget: Option<AiBudget>,
}
pub async fn update_budget(
State(state): State<AppState>,
_admin: AdminUser,
Json(req): Json<UpdateBudgetRequest>,
) -> Result<Json<UpdateBudgetResponse>, (StatusCode, Json<ErrorResponse>)> {
// Sjekk at noden er en person
let node_kind: Option<String> = sqlx::query_scalar(
"SELECT node_kind FROM nodes WHERE id = $1",
)
.bind(req.node_id)
.fetch_optional(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
if node_kind.as_deref() != Some("person") {
return Err((StatusCode::BAD_REQUEST, Json(ErrorResponse {
error: "Noden er ikke en person".to_string(),
})));
}
match req.monthly_limit_usd {
Some(limit) => {
// Sett budsjett
sqlx::query(
r#"UPDATE nodes
SET metadata = jsonb_set(
COALESCE(metadata, '{}'::jsonb),
'{ai_budget}',
$1::jsonb
)
WHERE id = $2"#,
)
.bind(serde_json::json!({ "monthly_limit_usd": limit }))
.bind(req.node_id)
.execute(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
tracing::info!(user_node_id = %req.node_id, limit, "AI-budsjett oppdatert");
Ok(Json(UpdateBudgetResponse {
node_id: req.node_id,
ai_budget: Some(AiBudget { monthly_limit_usd: limit }),
}))
}
None => {
// Fjern budsjett
sqlx::query(
"UPDATE nodes SET metadata = metadata - 'ai_budget' WHERE id = $1",
)
.bind(req.node_id)
.execute(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
tracing::info!(user_node_id = %req.node_id, "AI-budsjett fjernet");
Ok(Json(UpdateBudgetResponse {
node_id: req.node_id,
ai_budget: None,
}))
}
}
}