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:
parent
e1f45ae8a8
commit
63b188641e
4 changed files with 748 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
351
frontend/src/routes/admin/users/+page.svelte
Normal file
351
frontend/src/routes/admin/users/+page.svelte
Normal 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 nå';
|
||||
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>
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
317
maskinrommet/src/users_admin.rs
Normal file
317
maskinrommet/src/users_admin.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue