feat: organizations, invites, billing

This commit is contained in:
Maya
2025-11-16 11:36:02 -05:00
parent d0ed41f050
commit cfdf4dbeef
17 changed files with 239 additions and 189 deletions
+27 -39
View File
@@ -49,15 +49,22 @@ async fn register(
return Err(ApiError::forbidden("User registration is disabled"));
}
let user = state.services.auth_service.register(request).await?;
let (org_id, permissions) = match process_pending_invite(&state, &session).await {
Ok(Some((org_id, permissions))) => (Some(org_id), Some(permissions)),
Ok(_) => (None, None),
Err(e) => {
return Err(ApiError::internal_error(&format!(
"Failed to process invite: {}",
e
)));
}
};
if let Err(e) = process_pending_invite(&state, &session, user.id).await {
tracing::error!(
"Failed to process pending invite during registration: {}",
e
);
// Don't fail registration, just log the error
}
let user = state
.services
.auth_service
.register(request, org_id, permissions)
.await?;
session
.insert("user_id", user.id)
@@ -74,14 +81,6 @@ async fn login(
) -> ApiResult<Json<ApiResponse<User>>> {
let user = state.services.auth_service.login(request).await?;
if let Err(e) = process_pending_invite(&state, &session, user.id).await {
tracing::error!(
"Failed to process pending invite during registration: {}",
e
);
// Don't fail registration, just log the error
}
session
.insert("user_id", user.id)
.await
@@ -184,14 +183,6 @@ async fn oidc_authorize(
)
.await
.map_err(|e| ApiError::internal_error(&format!("Failed to save session: {}", e)))?;
session
.insert("oidc_organization_id", params.organization_id)
.await
.map_err(|e| ApiError::internal_error(&format!("Failed to save session: {}", e)))?;
session
.insert("oidc_permissions", params.permissions)
.await
.map_err(|e| ApiError::internal_error(&format!("Failed to save session: {}", e)))?;
Ok(Redirect::to(&auth_url))
}
@@ -297,15 +288,20 @@ async fn oidc_callback(
}
}
} else {
// LOGIN FLOW
let organization_id: Option<Uuid> = session
.remove("oidc_organization_id")
.await
.unwrap_or_default();
let permissions = session.remove("oidc_permissions").await.unwrap_or_default();
let (org_id, permissions) = match process_pending_invite(&state, &session).await {
Ok(Some((org_id, permissions))) => (Some(org_id), Some(permissions)),
Ok(_) => (None, None),
Err(e) => {
return Err(Redirect::to(&format!(
"{}?error={}",
return_url,
urlencoding::encode(&format!("Failed to process invite: {}", e))
)));
}
};
match oidc_service
.login_or_register(&params.code, pending_auth, organization_id, permissions)
.login_or_register(&params.code, pending_auth, org_id, permissions)
.await
{
Ok(user) => {
@@ -318,14 +314,6 @@ async fn oidc_callback(
)));
}
if let Err(e) = process_pending_invite(&state, &session, user.id).await {
tracing::error!(
"Failed to process pending invite during registration: {}",
e
);
// Don't fail registration, just log the error
}
// Clear session data
let _ = session.remove::<OidcPendingAuth>("oidc_pending_auth").await;
let _ = session.remove::<bool>("oidc_is_linking").await;
-6
View File
@@ -3,8 +3,6 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use validator::Validate;
use crate::server::users::r#impl::permissions::UserOrgPermissions;
/// Login request from client
/// Note: 'name' is used as the username
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
@@ -24,8 +22,6 @@ pub struct RegisterRequest {
#[validate(length(min = 12, message = "Password must be at least 12 characters"))]
#[validate(custom(function = "validate_password_complexity"))]
pub password: String,
pub organization_id: Option<Uuid>,
pub permissions: Option<UserOrgPermissions>,
}
/// Validate password complexity requirements
@@ -70,6 +66,4 @@ pub struct UpdateEmailPasswordRequest {
pub struct OidcAuthorizeParams {
pub link: Option<bool>,
pub return_url: Option<String>,
pub organization_id: Option<Uuid>,
pub permissions: Option<UserOrgPermissions>,
}
+2 -2
View File
@@ -186,7 +186,7 @@ impl OidcService {
&self,
code: &str,
pending_auth: OidcPendingAuth,
organization_id: Option<Uuid>,
org_id: Option<Uuid>,
permissions: Option<UserOrgPermissions>,
) -> Result<User> {
let user_info = self.exchange_code(code, pending_auth).await?;
@@ -218,7 +218,7 @@ impl OidcService {
email,
user_info.subject,
self.provider_name.clone(),
organization_id,
org_id,
permissions,
)
.await
+16 -8
View File
@@ -46,7 +46,12 @@ impl AuthService {
}
/// Register a new user with password
pub async fn register(&self, request: RegisterRequest) -> Result<User> {
pub async fn register(
&self,
request: RegisterRequest,
org_id: Option<Uuid>,
permissions: Option<UserOrgPermissions>,
) -> Result<User> {
request
.validate()
.map_err(|e| anyhow!("Validation failed: {}", e))?;
@@ -67,8 +72,8 @@ impl AuthService {
Some(hash_password(&request.password)?),
None,
None,
request.organization_id,
request.permissions,
org_id,
permissions,
)
.await
}
@@ -79,7 +84,7 @@ impl AuthService {
email: EmailAddress,
oidc_subject: String,
oidc_provider: String,
organization_id: Option<Uuid>,
org_id: Option<Uuid>,
permissions: Option<UserOrgPermissions>,
) -> Result<User> {
// Provision user with OIDC
@@ -88,7 +93,7 @@ impl AuthService {
None,
Some(oidc_subject),
Some(oidc_provider),
organization_id,
org_id,
permissions,
)
.await
@@ -101,7 +106,7 @@ impl AuthService {
password_hash: Option<String>,
oidc_subject: Option<String>,
oidc_provider: Option<String>,
organization_id: Option<Uuid>,
org_id: Option<Uuid>,
permissions: Option<UserOrgPermissions>,
) -> Result<User> {
let all_users = self
@@ -132,8 +137,8 @@ impl AuthService {
self.user_service.update(&mut seed_user).await
} else {
// Not first user - create new user with or without org
let org_id = if let Some(org_id) = organization_id {
// If being invited, use provied org ID, otherwise create a new one
let org_id = if let Some(org_id) = org_id {
org_id
} else {
// Create new organization for this user
@@ -150,6 +155,9 @@ impl AuthService {
organization.id
};
// If being invited, will have permissions; otherwise, new user and should be owner of org
let permissions = permissions.unwrap_or(UserOrgPermissions::Owner);
// Create user based on auth method
if let Some(hash) = password_hash {
self.user_service
+55 -42
View File
@@ -11,7 +11,7 @@ use crate::server::shared::types::api::ApiError;
use crate::server::shared::types::api::ApiResponse;
use crate::server::shared::types::api::ApiResult;
use crate::server::users::r#impl::permissions::UserOrgPermissions;
use anyhow::{Error, anyhow};
use anyhow::Error;
use axum::Json;
use axum::Router;
use axum::extract::Path;
@@ -219,24 +219,56 @@ async fn accept_invite_link(
// Check if user is already logged in
if let Ok(Some(user_id)) = session.get::<uuid::Uuid>("user_id").await {
// User is logged in - add them to the organization immediately
if let Err(e) = process_pending_invite(&state, &session, user_id).await {
tracing::error!("Failed to process invite for logged-in user: {}", e);
return Err(Redirect::to(&format!(
"/?error={}",
urlencoding::encode(&e.to_string())
)));
};
if let Some((org_id, permissions)) = process_pending_invite(&state, &session)
.await
.map_err(|e| {
tracing::error!("Failed to process invite for logged-in user: {}", e);
Redirect::to(&format!("/?error={}", urlencoding::encode(&e.to_string())))
})?
{
let mut user = state
.services
.user_service
.get_by_id(&user_id)
.await
.map_err(|_| {
Redirect::to(&format!(
"/?error={}",
urlencoding::encode(&format!(
"Failed get user to update organization {}",
user_id
))
))
})?
.ok_or_else(|| {
Redirect::to(&format!(
"/?error={}",
urlencoding::encode(&format!(
"Failed to update organization for user {}",
user_id
))
))
})?;
// Mark invite as used
if let Err(e) = state.services.organization_service.use_invite(&token).await {
tracing::error!("Failed to mark invite as used: {}", e);
user.base.organization_id = org_id;
user.base.permissions = permissions;
// Update user's organization
state
.services
.user_service
.update(&mut user)
.await
.map_err(|_| {
Redirect::to(&format!(
"/?error={}",
urlencoding::encode(&format!(
"Failed update user organization {}",
user_id
))
))
})?;
}
// Clear pending invite from session
let _ = session.remove::<uuid::Uuid>("pending_org_invite").await;
let _ = session.remove::<String>("pending_invite_token").await;
let _ = session.remove::<String>("pending_invite_permissions").await;
// Redirect to home
return Ok(Redirect::to("/"));
}
@@ -251,17 +283,16 @@ async fn accept_invite_link(
pub async fn process_pending_invite(
state: &Arc<AppState>,
session: &Session,
user_id: Uuid,
) -> Result<(), Error> {
) -> Result<Option<(Uuid, UserOrgPermissions)>, Error> {
// Check for pending invite in session
let pending_org_id = match session.get::<Uuid>("pending_org_invite").await {
Ok(Some(org_id)) => org_id,
_ => return Ok(()), // No pending invite
_ => return Ok(None), // No pending invite
};
let invite_token = match session.get::<String>("pending_invite_token").await {
Ok(Some(token)) => token,
_ => return Ok(()), // No token stored
_ => return Ok(None), // No token stored
};
let permissions = match session
@@ -269,32 +300,14 @@ pub async fn process_pending_invite(
.await
{
Ok(Some(permissions)) => permissions,
_ => return Ok(()), // No token stored
_ => return Ok(None), // No permissions stored
};
tracing::info!(
"Processing pending invite for user {} to organization {}",
user_id,
"Processing pending invite to organization {}",
pending_org_id
);
let mut user = state
.services
.user_service
.get_by_id(&user_id)
.await?
.ok_or_else(|| anyhow!("Failed to update organization for user {}", user_id))?;
user.base.organization_id = pending_org_id;
user.base.permissions = permissions;
// Update user's organization
state
.services
.user_service
.update(&mut user)
.await
.map_err(|e| anyhow!("Failed to add user to organization: {}", e))?;
// Mark invite as used
if let Err(e) = state
.services
@@ -303,12 +316,12 @@ pub async fn process_pending_invite(
.await
{
tracing::error!("Failed to mark invite as used: {}", e);
// Don't fail the whole operation if we can't mark it as used
}
// Clear session data
let _ = session.remove::<Uuid>("pending_org_invite").await;
let _ = session.remove::<String>("pending_invite_token").await;
let _ = session.remove::<String>("pending_invite_permissions").await;
Ok(())
Ok(Some((pending_org_id, permissions)))
}
+2 -4
View File
@@ -71,9 +71,8 @@ impl UserService {
oidc_subject: String,
oidc_provider: Option<String>,
organization_id: Uuid,
permissions: Option<UserOrgPermissions>,
permissions: UserOrgPermissions,
) -> Result<User, Error> {
let permissions = permissions.unwrap_or(UserOrgPermissions::Owner);
let user = User::new(UserBase::new_oidc(
email,
oidc_subject,
@@ -91,9 +90,8 @@ impl UserService {
email: EmailAddress,
password_hash: String,
organization_id: Uuid,
permissions: Option<UserOrgPermissions>,
permissions: UserOrgPermissions,
) -> Result<User, Error> {
let permissions = permissions.unwrap_or(UserOrgPermissions::Owner);
let user = User::new(UserBase::new_password(
email,
password_hash,
+35 -8
View File
@@ -8,6 +8,7 @@ use netvisor::server::organizations::r#impl::base::Organization;
use netvisor::server::services::definitions::ServiceDefinitionRegistry;
use netvisor::server::services::definitions::home_assistant::HomeAssistant;
use netvisor::server::services::r#impl::base::Service;
use netvisor::server::shared::handlers::factory::OnboardingRequest;
use netvisor::server::shared::types::api::ApiResponse;
use netvisor::server::shared::types::metadata::HasId;
use netvisor::server::users::r#impl::base::User;
@@ -100,8 +101,6 @@ impl TestClient {
let register_request = RegisterRequest {
email: email.clone(),
password: password.to_string(),
organization_id: None,
permissions: None,
};
let response = self
@@ -146,6 +145,23 @@ impl TestClient {
.await
}
/// Onboarding request
async fn onboard_request(&self) -> Result<Organization, String> {
let response = self
.client
.post(format!("{}/api/onboarding", BASE_URL))
.json(&OnboardingRequest {
organization_name: "My Organization".to_string(),
network_name: "My Network".to_string(),
populate_seed_data: true,
})
.send()
.await
.map_err(|e| format!("POST /onboarding failed: {}", e))?;
self.parse_response(response, "POST /onboarding").await
}
/// Parse API response
async fn parse_response<T: serde::de::DeserializeOwned>(
&self,
@@ -223,7 +239,7 @@ async fn setup_authenticated_user(client: &TestClient) -> Result<User, String> {
let test_email: EmailAddress = EmailAddress::new_unchecked("user@example.com");
// Try to register (will fail if user exists, which is fine)
// Try to register
match client.register(&test_email, TEST_PASSWORD).await {
Ok(user) => {
println!("✅ Registered new user: {}", user.base.email);
@@ -240,12 +256,18 @@ async fn setup_authenticated_user(client: &TestClient) -> Result<User, String> {
async fn wait_for_organization(client: &TestClient) -> Result<Organization, String> {
retry("wait for organization to be created", 15, 2, || async {
let organization: Vec<Organization> = client.get("/api/organizations").await?;
let organization: Option<Organization> = client.get("/api/organizations").await?;
organization
.first()
.cloned()
.ok_or_else(|| "No networks found yet".to_string())
organization.ok_or_else(|| "No networks found yet".to_string())
})
.await
}
async fn onboard(client: &TestClient) -> Result<(), String> {
retry("wait for organization to be created", 15, 2, || async {
let _org = client.onboard_request().await?;
Ok(())
})
.await
}
@@ -492,6 +514,11 @@ async fn test_full_integration() {
.expect("Failed to find organization");
println!("✅ Organization: {}", organization.base.name);
// Onboard
println!("\n=== Onboarding ===");
onboard(&client).await.expect("Failed to onboard");
println!("✅ Onboarded");
// Wait for network
println!("\n=== Waiting for Network ===");
let network = wait_for_network(&client)
@@ -15,9 +15,7 @@
let formData: RegisterRequest & { confirmPassword: string } = {
email: '',
password: '',
confirmPassword: '',
permissions: null,
organization_id: null
confirmPassword: ''
};
// Reset form when modal opens
@@ -29,9 +27,7 @@
formData = {
email: '',
password: '',
confirmPassword: '',
permissions: null,
organization_id: null
confirmPassword: ''
};
}
@@ -41,9 +37,7 @@
// Only pass username and password to onRegister
await onRegister({
email: formData.email,
password: formData.password,
permissions: null,
organization_id: null
password: formData.password
});
} finally {
loading = false;
-2
View File
@@ -6,8 +6,6 @@ export interface LoginRequest {
export interface RegisterRequest {
email: string;
password: string;
organization_id: string | null;
permissions: string | null;
}
export interface SessionUser {
+4 -13
View File
@@ -8,19 +8,10 @@ export const organization = writable<Organization | null>();
export const invites = writable<OrganizationInvite[]>([]);
export async function onboard(request: OnboardingRequest): Promise<void> {
await api.request<Organization, Organization | null>(
'/onboarding',
organization,
(org) => {
console.log('Onboarding response:', org);
console.log('is_onboarded:', org.is_onboarded);
return org;
},
{
method: 'POST',
body: JSON.stringify(request)
}
);
await api.request<Organization, Organization | null>('/onboarding', organization, (org) => org, {
method: 'POST',
body: JSON.stringify(request)
});
}
export async function getOrganization(): Promise<Organization | null> {
+3 -9
View File
@@ -3,21 +3,15 @@ import { pushError } from '../stores/feedback';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function loadData(loaders: (() => Promise<any>)[]) {
const loading = writable(false);
const loading = writable(true); // Start as true immediately
const loadingTimeout = setTimeout(() => {
loading.set(true);
}, 500);
// Start loading immediately
// Start loading
(async () => {
try {
await Promise.all(loaders.map((loader) => loader()));
clearTimeout(loadingTimeout);
loading.set(false);
} catch (error) {
pushError(`'Data loading failed:', ${error}`);
clearTimeout(loadingTimeout);
pushError(`Data loading failed: ${error}`);
loading.set(false);
}
})();
+48
View File
@@ -0,0 +1,48 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { get } from 'svelte/store';
import { organization } from '$lib/features/organizations/store';
import { config } from '$lib/shared/stores/config';
import { isBillingPlanActive } from '$lib/features/organizations/types';
import { currentUser } from '$lib/features/auth/store';
/**
* Determines the correct route for an authenticated user based on their state
*/
export function getRoute(): string {
const $organization = get(organization);
const $config = get(config);
const $currentUser = get(currentUser);
if (!$organization) {
return resolve('/');
}
// Check onboarding first
if (!$organization.is_onboarded && $currentUser?.permissions === 'Owner') {
return resolve('/onboarding');
}
// If not onboarded and not owner, they're stuck (shouldn't happen normally)
if (!$organization.is_onboarded) {
return resolve('/');
}
// Check billing if enabled
const billingEnabled = $config?.billing_enabled ?? false;
if (billingEnabled && !isBillingPlanActive($organization)) {
return resolve('/billing');
}
// All checks passed - go to main app
return resolve('/');
}
/**
* Navigate to the appropriate route after authentication
*/
export async function navigate(): Promise<void> {
const route = getRoute();
// eslint-disable-next-line svelte/no-navigation-without-resolve
await goto(route);
}
+22 -35
View File
@@ -14,18 +14,16 @@
import { networks } from '$lib/features/networks/store';
import { subnets } from '$lib/features/subnets/store';
import { pushError, pushSuccess } from '$lib/shared/stores/feedback';
import { config, getConfig } from '$lib/shared/stores/config';
import { getMetadata } from '$lib/shared/stores/metadata';
import { getConfig } from '$lib/shared/stores/config';
import { getOrganization, organization } from '$lib/features/organizations/store';
import { isBillingPlanActive } from '$lib/features/organizations/types';
import { getCurrentBillingPlans } from '$lib/features/billing/store';
import { getRoute } from '$lib/shared/utils/navigation';
import { apiKeys } from '$lib/features/api_keys/store';
import { daemons } from '$lib/features/daemons/store';
// Accept children as a snippet prop
let { children }: { children: Snippet } = $props();
// Reactive values from stores
let billingEnabled = $derived($config ? $config.billing_enabled : false);
// Effect to reset data when user logs out
$effect(() => {
if (!$isAuthenticated) {
@@ -34,6 +32,9 @@
services.set([]);
subnets.set([]);
groups.set([]);
organization.set(null);
apiKeys.set([]);
daemons.set([]);
networks.set([]);
}
});
@@ -56,7 +57,6 @@
}
onMount(async () => {
await getOrganization();
const sessionId = $page.url.searchParams.get('session_id');
// Check for OIDC error in URL
@@ -71,7 +71,7 @@
}
// Check authentication status and get public server config
await Promise.all([checkAuth(), getConfig(), getMetadata()]);
await Promise.all([checkAuth(), getConfig()]);
// Redirect to auth page if not authenticated and not already there
if (!$isAuthenticated) {
@@ -80,24 +80,10 @@
await goto(`${resolve('/auth')}${$page.url.search}`);
}
} else {
await getOrganization();
if ($organization) {
// Check onboarding - don't redirect if already on onboarding page
if (!$organization.is_onboarded) {
if ($page.url.pathname !== '/onboarding') {
await goto(resolve('/onboarding'));
}
return; // Stop here regardless of whether we redirected or not
}
// Only check billing if onboarded
if (!billingEnabled) {
if ($page.url.pathname !== '/') {
await goto(resolve('/'));
}
return;
}
// Billing logic (only runs if onboarded)
// Handle Stripe session callback (billing activation)
if (sessionId && !isBillingPlanActive($organization)) {
// Clean up URL first
const cleanUrl = new URL($page.url);
@@ -107,18 +93,19 @@
// Poll for webhook to complete
const activated = await waitForBillingActivation();
if (activated) {
await goto(resolve('/'));
// After billing is activated, navigate to correct route
const correctRoute = getRoute();
// eslint-disable-next-line svelte/no-navigation-without-resolve
await goto(correctRoute);
return;
}
} else if (isBillingPlanActive($organization)) {
if ($page.url.pathname === '/billing') {
await goto(resolve('/'));
}
} else if (!isBillingPlanActive($organization)) {
if ($page.url.pathname !== '/billing') {
await getCurrentBillingPlans();
await goto(resolve('/billing'));
}
}
// Determine correct route and redirect if needed
const correctRoute = getRoute();
if ($page.url.pathname !== correctRoute) {
// eslint-disable-next-line svelte/no-navigation-without-resolve
await goto(correctRoute);
}
} else {
pushError('Failed to load organization. Please refresh the page.');
+2 -1
View File
@@ -12,6 +12,7 @@
import { startDiscoverySSE } from '$lib/features/discovery/SSEStore';
import { isAuthenticated, isCheckingAuth } from '$lib/features/auth/store';
import type { Component } from 'svelte';
import { getMetadata } from '$lib/shared/stores/metadata';
// Read hash immediately during script initialization, before onMount
const initialHash = typeof window !== 'undefined' ? window.location.hash.substring(1) : '';
@@ -46,7 +47,7 @@
if (dataLoadingStarted) return;
dataLoadingStarted = true;
await getNetworks();
await Promise.all([getNetworks(), getMetadata()]);
// Load initial data
storeWatcherUnsubs = [
+17 -9
View File
@@ -1,11 +1,13 @@
<script lang="ts">
import { login, register } from '$lib/features/auth/store';
import { checkAuth, login, register } from '$lib/features/auth/store';
import LoginModal from '$lib/features/auth/components/LoginModal.svelte';
import RegisterModal from '$lib/features/auth/components/RegisterModal.svelte';
import type { LoginRequest, RegisterRequest } from '$lib/features/auth/types/base';
import Toast from '$lib/shared/components/feedback/Toast.svelte';
import GithubStars from '$lib/shared/components/data/GithubStars.svelte';
import { page } from '$app/stores';
import { getOrganization } from '$lib/features/organizations/store';
import { navigate } from '$lib/shared/utils/navigation';
let showLogin = $state(true);
@@ -14,18 +16,24 @@
async function handleLogin(data: LoginRequest) {
const user = await login(data);
if (!user) {
return;
}
window.location.href = '/';
if (!user) return;
// Refresh auth state and organization
await Promise.all([checkAuth(), getOrganization()]);
// Navigate to correct destination
await navigate();
}
async function handleRegister(data: RegisterRequest) {
const user = await register(data);
if (!user) {
return;
}
window.location.href = '/';
if (!user) return;
// Refresh auth state and organization
await Promise.all([checkAuth(), getOrganization()]);
// Navigate to correct destination
await navigate();
}
function switchToRegister() {
+1 -1
View File
@@ -7,7 +7,7 @@
import Loading from '$lib/shared/components/feedback/Loading.svelte';
import { getCurrentBillingPlans } from '$lib/features/billing/store';
const loading = loadData([getConfig, getMetadata, getCurrentBillingPlans]);
const loading = loadData([getCurrentBillingPlans, getConfig, getMetadata]);
</script>
{#if $loading}
+2 -1
View File
@@ -5,10 +5,11 @@
import GithubStars from '$lib/shared/components/data/GithubStars.svelte';
import type { OnboardingRequest } from '$lib/features/auth/types/base';
import { onboard } from '$lib/features/organizations/store';
import { navigate } from '$lib/shared/utils/navigation';
async function handleSubmit(formData: OnboardingRequest) {
await onboard(formData);
window.location.href = '/';
await navigate();
}
function handleClose() {