From 63b188641ec4b2dddd6fe4ee5aebcc7483e19890 Mon Sep 17 00:00:00 2001 From: vegard Date: Thu, 19 Mar 2026 19:19:10 +0000 Subject: [PATCH] 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 --- frontend/src/lib/api.ts | 75 ++++ frontend/src/routes/admin/users/+page.svelte | 351 +++++++++++++++++++ maskinrommet/src/main.rs | 5 + maskinrommet/src/users_admin.rs | 317 +++++++++++++++++ 4 files changed, 748 insertions(+) create mode 100644 frontend/src/routes/admin/users/+page.svelte create mode 100644 maskinrommet/src/users_admin.rs diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5ffc685..0be76c5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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; + 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 { + 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 { + 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 { + return post(accessToken, '/admin/users/budget', { + node_id: nodeId, + monthly_limit_usd: monthlyLimitUsd + }); +} + export async function setMixerRole( accessToken: string, roomId: string, diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte new file mode 100644 index 0000000..4a4bc31 --- /dev/null +++ b/frontend/src/routes/admin/users/+page.svelte @@ -0,0 +1,351 @@ + + +
+
+
+
+ Admin + / +

Brukere

+
+ {#if data} + {data.total_count} brukere + {/if} +
+
+ +
+ {#if !accessToken} +

Logg inn for tilgang.

+ {:else if !data} +

Laster brukere...

+ {:else} + + {#if error} +
+ {error} + +
+ {/if} + + +
+ +
+ + +
+ {#each filteredUsers as user (user.node_id)} +
+
+ +
+
+ {user.title} + {#if user.is_active} + + + Aktiv + + {:else} + + + Deaktivert + + {/if} +
+
+ {#if user.email} + {user.email} + {/if} + Opprettet: {formatTime(user.created_at)} + Siste aktivitet: {timeAgo(user.last_activity)} +
+
+ {user.node_id} +
+
+ + + +
+ + +
+ +
+
Roller
+ {#if user.roles.length > 0} +
+ {#each user.roles as role} + + {roleLabel(role.role)} i {role.collection_title} + + {/each} +
+ {:else} + Ingen roller + {/if} +
+ + +
+
AI-budsjett
+ {#if editingBudget === user.node_id} +
+ $ + { + if (e.key === 'Enter') saveBudget(user.node_id); + if (e.key === 'Escape') cancelEditBudget(); + }} + /> + /mnd + + +
+ {:else} +
+ {#if user.ai_budget} + + ${user.ai_usage_this_month.toFixed(2)} / ${user.ai_budget.monthly_limit_usd.toFixed(0)} + + {:else} + + {#if user.ai_usage_this_month > 0} + ${user.ai_usage_this_month.toFixed(2)} (ingen grense) + {:else} + Ingen grense + {/if} + + {/if} + +
+ {/if} +
+
+
+ {:else} +
+ {#if filter} + Ingen brukere matcher filteret. + {:else} + Ingen brukere funnet. + {/if} +
+ {/each} +
+ {/if} +
+
diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 5c01558..42d8898 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -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)) diff --git a/maskinrommet/src/users_admin.rs b/maskinrommet/src/users_admin.rs new file mode 100644 index 0000000..0bce3a5 --- /dev/null +++ b/maskinrommet/src/users_admin.rs @@ -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) { + (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, + pub visibility: String, + pub created_at: DateTime, + pub metadata: serde_json::Value, + pub is_active: bool, + pub roles: Vec, + pub ai_budget: Option, + pub ai_usage_this_month: f64, + pub last_activity: Option>, +} + +#[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, + pub total_count: i64, +} + +pub async fn users_overview( + State(state): State, + _admin: AdminUser, +) -> Result, (StatusCode, Json)> { + // Hent alle person-noder med auth-identitet + #[derive(sqlx::FromRow)] + struct UserRow { + id: Uuid, + title: Option, + visibility: String, + metadata: serde_json::Value, + created_at: DateTime, + email: Option, + } + + 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, + 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::(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> = 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, + _admin: AdminUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // Sjekk at noden er en person + let node_kind: Option = 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, +} + +#[derive(Serialize)] +pub struct UpdateBudgetResponse { + pub node_id: Uuid, + pub ai_budget: Option, +} + +pub async fn update_budget( + State(state): State, + _admin: AdminUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // Sjekk at noden er en person + let node_kind: Option = 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, + })) + } + } +}