mirror of
https://github.com/mayanayza/netvisor.git
synced 2025-12-10 08:24:08 -06:00
feat: organizations, invites, billing
This commit is contained in:
@@ -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(¶ms.code, pending_auth, organization_id, permissions)
|
||||
.login_or_register(¶ms.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;
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,8 +6,6 @@ export interface LoginRequest {
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
organization_id: string | null;
|
||||
permissions: string | null;
|
||||
}
|
||||
|
||||
export interface SessionUser {
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.');
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user