fix: sync saas changes (#10188)

### **User description**
This sync the aws and azure saas related changes and cloud org related
changes from v0.50.0 branch to main.

Reviewers, please specifically take a look at [fix: auxm related
changes](https://github.com/openobserve/openobserve/commit/1e278e9b5121d2df9757b3908ab27801bdc06383)
commit, which does changes needed for auxm from actix web. Note that
router changes have been done in the respective saas commit itself, as
it caused conflicts.

NOTE: This has not built locally to validate that everything at least
compiles, as at the time of writing the main branch is not compiling
with ent.


___

### **PR Type**
Enhancement


___

### **Description**
- AWS Marketplace onboarding: UI, API routes, services

- Azure Marketplace onboarding: UI, API routes, services

- Billing UI adapts to AWS/Azure providers

- Add HttpResponse.created/found helpers

- Add distributed metering lock and cloud jobs

- Enforce free-tier org creation limits


___



<details> <summary><h3> File Walkthrough</h3></summary>

<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Configuration
changes</strong></td><td><details><summary>3 files</summary><table>
<tr>
<td><strong>router.ts</strong><dd><code>Add AWS/Azure marketplace setup
routes</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-5cf8b2f94d45462f216d1b74cec6e139cbc94e6f5677bba1e7b55a30aaa95792">+31/-1</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>mod.rs</strong><dd><code>Expose aws and azure marketplace
modules</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-01aa8706a5e31c9d66036669676b55c4f50e8acbbd3323a1979dc43b9611b9a2">+2/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>mod.rs</strong><dd><code>Register marketplace webhook and
API routes</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-02556e3f515441b5d2b6bbea7829df488beac03c4cc41309a6638ae3c0e0aec6">+23/-1</a>&nbsp;
&nbsp; </td>

</tr>

</table></details></td></tr><tr><td><strong>Enhancement</strong></td><td><details><summary>16
files</summary><table>
<tr>
<td><strong>awsMarketplace.ts</strong><dd><code>Implement AWS
Marketplace HTTP client</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-a84cf89c9eec197f49a3eafff23d45d539f711282b60175a80cb96255e877661">+64/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>azureMarketplace.ts</strong><dd><code>Implement Azure
Marketplace HTTP client</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-45edda85f9d7b0e2ec5a750d112b0b52d22dce0c48aa8361e30d51cacfeee16c">+51/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>http.rs</strong><dd><code>Add HttpResponse.created and found
helpers</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-e1831572a854319ed0a69e803514466aea13a4924f4fa02f58dbf508c30a19fd">+18/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>billings.rs</strong><dd><code>Include provider field in
subscription model</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-986786190d086283f70a66cc487eadc68784dd4200f8a32e6cf4c50a3d6603ca">+3/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>aws_marketplace.rs</strong><dd><code>Add AWS Marketplace
registration and linking handlers</code>&nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-03b179d714809fbccd4a85bbd783f2399540b515796f5afb0060ff886f872b87">+364/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>azure_marketplace.rs</strong><dd><code>Add Azure Marketplace
linking handler</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-da9cf01b26c5f642ceebba9d4355bc45ccb518b4a191c38dbe3178f42884b153">+148/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>billings.rs</strong><dd><code>Enhance billing webhook and
redirect logging</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-725936e3d6e955175e6100e8ca6f4437f334a2b554d6f11fa349ee9cbac70425">+64/-5</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>mod.rs</strong><dd><code>Add distributed metering lock
logic</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-f2e4c29b8eef22189ec704a832633c4bd135a24444f054864c98064bd0963681">+67/-2</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>organization.rs</strong><dd><code>Enforce free org limits in
org workflow</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-6b3c3c2b027c1158c0d41302cc69dfcd20ab68a6a4fa365cda403f838940bbb4">+40/-13</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>Billing.vue</strong><dd><code>Conditional invoice tab based
on provider</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-08d804667873a5d984027ad69438432a14973e0318cf78ad981bc7b9a31c1bf4">+30/-2</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>TrialPeriod.vue</strong><dd><code>Hide trial message for
AWS-billed orgs</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-cc999442352001a0c7a756db0974f99ec3a21d566e9082d1b113c85ca897dafa">+25/-3</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>plans.vue</strong><dd><code>Adapt plan view to billing
providers</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-a85ef89681b89f6ae045ed1c45bd3e4aa8c494a7784a8a56514def1af6eef896">+6/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>proPlan.vue</strong><dd><code>Display AWS/Azure billing chip
in plan card</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-b5678b2763909484d6475adf5af690b3d382d8438520adcfc1428b66cf7ad7d5">+28/-2</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>AwsMarketplaceSetup.vue</strong><dd><code>Add AWS
Marketplace setup Vue view</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-7345a61d1ba6216718c48067d9c010f069fb43222a15094b4644474eb9091cae">+445/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>AzureMarketplaceSetup.vue</strong><dd><code>Add Azure
Marketplace setup Vue view</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-5e29364b67f1e0a714d1d219bc4b619229068ef6e0b2312cedeabf720a0b25f1">+371/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>Login.vue</strong><dd><code>Support marketplace redirects in
login flow</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-a135c379ef6ac8f92199ea5f7ff19a227971a9b0a87ccccf87c0268740522c2c">+21/-0</a>&nbsp;
&nbsp; </td>

</tr>

</table></details></td></tr><tr><td><strong>Documentation</strong></td><td><details><summary>1
files</summary><table>
<tr>
<td><strong>en.json</strong><dd><code>Add AWS/Azure marketplace
localization text</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10188/files#diff-892aa3d75ab6e129ef9116aa21bec1b9a15ba69ce5366d2a19128078c690d824">+35/-0</a>&nbsp;
&nbsp; </td>

</tr>
</table></details></td></tr></tr></tbody></table>

</details>

___

---------

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>
Co-authored-by: Yashodhan Joshi <yjdoc2@gmail.com>
Co-authored-by: udev <uddhav@openobserve.ai>
Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Yashodhan Joshi
2026-01-28 13:53:13 +05:30
committed by GitHub
co-authored by Yashodhan Joshi udev sai nikhil kethe github-actions[bot]
parent 5426673cbd
commit 6b29f3cdc9
33 changed files with 2351 additions and 92 deletions
+18
View File
@@ -121,6 +121,24 @@ impl HttpResponse {
.into_response()
}
pub fn created(msg: impl ToString) -> Response {
(
StatusCode::CREATED,
Json(Self::message(StatusCode::CREATED, msg.to_string())),
)
.into_response()
}
pub fn found(loc: impl ToString, cookie: Option<String>) -> Response {
let mut builder = Response::builder()
.status(StatusCode::FOUND)
.header("Location", loc.to_string());
if let Some(c) = cookie {
builder = builder.header("Set-Cookie", c)
}
builder.body(().into()).unwrap()
}
/// Send a BadRequest response in json format and associate the
/// provided error as `error` field.
pub fn bad_request(error: impl ToString) -> Response {
+3
View File
@@ -39,6 +39,8 @@ pub struct ListSubscriptionResponseBody {
pub subscription_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub customer_id: Option<String>,
/// Billing provider: "stripe" or "aws"
pub provider: String,
}
impl From<cloud_billings::CustomerBilling> for ListSubscriptionResponseBody {
@@ -46,6 +48,7 @@ impl From<cloud_billings::CustomerBilling> for ListSubscriptionResponseBody {
Self {
subscription_type: value.subscription_type.to_string(),
customer_id: value.customer_id,
provider: value.provider.to_string(),
}
}
}
@@ -0,0 +1,368 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use axum::{
extract::{Path, Query},
response::Response,
};
use axum_extra::extract::cookie::{Cookie, SameSite};
use config::meta::user::UserRole;
use o2_enterprise::enterprise::aws_marketplace::{api as aws_mp_api, db as aws_mp_db};
use serde::{Deserialize, Serialize};
use crate::{
common::{meta::http::HttpResponse as MetaHttpResponse, utils::auth::UserEmail},
handler::http::extractors::Headers,
service::{organization, users},
};
/// Form data from AWS Marketplace POST
#[derive(Debug, Deserialize)]
pub struct AwsMarketplaceRegistrationForm {
#[serde(rename = "x-amzn-marketplace-token")]
pub token: String,
#[serde(rename = "x-amzn-marketplace-agreement-id")]
pub agreement_id: Option<String>,
#[serde(rename = "x-amzn-marketplace-product-id")]
pub product_id: Option<String>,
#[serde(rename = "x-amzn-marketplace-fulfillment-url")]
pub fulfillment_url: Option<String>,
}
/// AwsMarketplaceRegistration - Entry point from AWS Marketplace
///
/// This endpoint receives the POST from AWS Marketplace when a customer subscribes.
/// It saves the token in a cookie and redirects to the login page.
/// Full path: POST /marketplace/aws/register
pub async fn aws_marketplace_register(
axum::Form(form): axum::Form<AwsMarketplaceRegistrationForm>,
) -> Response {
log::info!(
"[AWS SAAS] Received marketplace registration: product_id={:?}, agreement_id={:?}",
form.product_id,
form.agreement_id
);
// Get the Dex login URL
let login_url = {
use o2_dex::service::auth::get_dex_login;
let login_data = get_dex_login();
// Store PKCE state
let state = login_data.state.clone();
if let Err(e) = crate::service::kv::set(
crate::handler::http::auth::validator::PKCE_STATE_ORG,
&state,
state.clone().into(),
)
.await
{
log::error!("[AWS SAAS] Failed to store PKCE state: {}", e);
}
login_data.url
};
// Create cookie with the marketplace token
// Use SameSite::Lax to allow the cookie to be sent on redirect from Dex
let mut cookie = Cookie::new("aws_marketplace_token", form.token.clone());
cookie.set_path("/");
cookie.set_http_only(false); // Allow JavaScript to read it
cookie.set_secure(true);
cookie.set_same_site(SameSite::Lax);
cookie.set_expires(time::OffsetDateTime::now_utc() + time::Duration::hours(4)); // Token expires in 4 hours
log::info!("[AWS SAAS] Redirecting to login: {}", login_url);
MetaHttpResponse::found(login_url, Some(cookie.to_string()))
}
/// Request payload for linking AWS Marketplace subscription
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct LinkSubscriptionRequest {
pub token: String,
}
/// Response from link-subscription endpoint
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct LinkSubscriptionResponse {
pub success: bool,
pub customer_identifier: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
/// LinkAwsMarketplaceSubscription
#[utoipa::path(
post,
path="/{org_id}/aws-marketplace/link-subscription",
context_path = "/api",
tag = "AWS Marketplace",
operation_id = "LinkAwsMarketplaceSubscription",
summary = "Link AWS Marketplace subscription to organization",
description = "Exchanges AWS Marketplace token for customer info and creates pending subscription",
security(
("Authorization" = [])
),
request_body(content = LinkSubscriptionRequest),
responses(
(status = 200, description = "Success", content_type = "application/json", body = LinkSubscriptionResponse),
(status = 400, description = "Bad Request (token expired, invalid)", content_type = "application/json", body = ()),
(status = 403, description = "Forbidden (not admin)", content_type = "application/json", body = ()),
(status = 409, description = "Conflict (already subscribed)", content_type = "application/json", body = ()),
(status = 500, description = "Internal Server Error", content_type = "application/json", body = ()),
),
)]
pub async fn link_subscription(
Path(org_id): Path<String>,
Headers(user_email): Headers<UserEmail>,
axum::Json(req): axum::Json<LinkSubscriptionRequest>,
) -> Response {
let email = user_email.user_id.as_str();
let org_id = org_id.as_str();
log::info!(
"[AWS SAAS] Link subscription request: org_id={}, user={}",
org_id,
email
);
// 1. Validate organization exists
let _org = match organization::get_org(org_id).await {
Some(org) => org,
None => {
return MetaHttpResponse::not_found("Organization not found");
}
};
// 2. Verify user is admin of the organization
let user = match users::get_user(Some(org_id), email).await {
Some(u) => u,
None => {
return MetaHttpResponse::forbidden(
"User not found or not authorized for this organization",
);
}
};
// Only admins and root users can link AWS Marketplace subscriptions
if user.role != UserRole::Admin && user.role != UserRole::Root {
return MetaHttpResponse::forbidden(
"Only organization administrators can link AWS Marketplace subscriptions",
);
}
// 3. Check if org is free tier (cannot link paid orgs)
match organization::is_org_in_free_trial_period(org_id).await {
Ok(false) => {
return MetaHttpResponse::bad_request(
"Only free tier organizations can be linked to AWS Marketplace",
);
}
Err(e) => {
log::error!("[AWS SAAS] Error checking free trial status: {}", e);
return MetaHttpResponse::internal_error("Failed to check organization status");
}
Ok(true) => {} // Continue - org is in free trial
}
// 4. Check if org is already linked to marketplace
match aws_mp_db::get_by_org_id(org_id).await {
Ok(Some(existing))
if existing.marketplace_status == aws_mp_api::MarketplaceStatus::Active =>
{
return MetaHttpResponse::conflict(
"This organization is already linked to AWS Marketplace",
);
}
Err(e) => {
log::error!("[AWS SAAS] Database error checking org: {}", e);
return MetaHttpResponse::internal_error("Failed to check organization status");
}
_ => {} // OK to proceed
}
// 5. Resolve AWS Marketplace token
let resolved = match aws_mp_api::resolve_customer_token(&req.token).await {
Ok(customer) => customer,
Err(e) => {
log::error!("[AWS SAAS] Failed to resolve customer token: {}", e);
if e.to_string().contains("TokenExpired") || e.to_string().contains("expired") {
return MetaHttpResponse::bad_request(
"Your registration link has expired. Please subscribe again from AWS Marketplace.",
);
}
return MetaHttpResponse::internal_error(format!(
"Failed to validate AWS Marketplace token: {}",
e
));
}
};
// 6. Check if customer_identifier already exists
match aws_mp_db::get_by_marketplace_customer_id(&resolved.customer_identifier).await {
Ok(Some(existing)) => {
use o2_enterprise::enterprise::aws_marketplace::db::ExistingSubscriptionAction;
let action = aws_mp_db::handle_existing_subscription(
&existing,
org_id,
&resolved.customer_identifier,
);
return match action {
ExistingSubscriptionAction::AlreadySubscribed {
customer_identifier,
} => MetaHttpResponse::json(serde_json::json!({
"success": true,
"customer_identifier": customer_identifier,
"message": "You are already subscribed!"
})),
ExistingSubscriptionAction::LinkedToDifferentOrg { existing_org_id } => {
MetaHttpResponse::conflict(format!(
"This AWS account is already linked to organization '{existing_org_id}'. Each AWS account can only have one active subscription.",
))
}
ExistingSubscriptionAction::PendingActivation {
customer_identifier,
} => MetaHttpResponse::json(serde_json::json!({
"success": true,
"customer_identifier": customer_identifier,
"message": "Subscription is being activated..."
})),
ExistingSubscriptionAction::StalePending => MetaHttpResponse::conflict(
"A pending subscription exists but was not completed. Please contact support.",
),
ExistingSubscriptionAction::PreviousSubscriptionExists { status } => {
MetaHttpResponse::conflict(format!(
"You previously had a subscription (status: {}). Please contact support to reactivate.",
status
))
}
};
}
Ok(None) => {} // Good, proceed with creation
Err(e) => {
log::error!("[AWS SAAS] Database error checking customer_id: {}", e);
return MetaHttpResponse::internal_error("Failed to check subscription status");
}
}
// 7. Create pending subscription
match aws_mp_db::create_pending_subscription(email, org_id, &resolved).await {
Ok(_) => {
log::info!(
"[AWS SAAS] Created pending subscription: customer_id={}, org_id={}",
resolved.customer_identifier,
org_id
);
MetaHttpResponse::json(LinkSubscriptionResponse {
success: true,
customer_identifier: resolved.customer_identifier,
message: None,
})
}
Err(e) => {
log::error!("[AWS SAAS] Failed to create pending subscription: {}", e);
MetaHttpResponse::internal_error(format!("Failed to create subscription record: {}", e))
}
}
}
/// GetAwsMarketplaceActivationStatus
#[utoipa::path(
get,
path="/{org_id}/aws-marketplace/activation-status",
context_path = "/api",
tag = "AWS Marketplace",
operation_id = "GetAwsMarketplaceActivationStatus",
summary = "Check AWS Marketplace subscription activation status",
description = "Poll this endpoint to check if AWS Marketplace subscription is activated. Requires admin role.",
security(
("Authorization" = [])
),
params(
("org_id" = String, Path, description = "Organization identifier"),
("customer_id" = Option<String>, Query, description = "Optional AWS Marketplace customer identifier for verification"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
(status = 400, description = "Bad Request (customer_id mismatch)", content_type = "application/json", body = ()),
(status = 403, description = "Forbidden (not admin)", content_type = "application/json", body = ()),
(status = 404, description = "Not Found", content_type = "application/json", body = ()),
(status = 500, description = "Internal Server Error", content_type = "application/json", body = ()),
),
)]
pub async fn activation_status(
Path(org_id): Path<String>,
Query(query): Query<std::collections::HashMap<String, String>>,
Headers(user_email): Headers<UserEmail>,
) -> Response {
let email = user_email.user_id.as_str();
let org_id = org_id.as_str();
log::debug!(
"[AWS SAAS] Checking activation status for org_id={}",
org_id
);
// Verify user has access to this org and is an admin
let user = match users::get_user(Some(org_id), email).await {
Some(u) => u,
None => {
return MetaHttpResponse::forbidden(
"User not found or not authorized for this organization",
);
}
};
// Only admins and root users can check subscription status
if user.role != UserRole::Admin && user.role != UserRole::Root {
return MetaHttpResponse::forbidden(
"Only organization administrators can check subscription status",
);
}
// Get the subscription for this organization
let billing = match aws_mp_db::get_by_org_id(org_id).await {
Ok(Some(billing)) => billing,
Ok(None) => {
return MetaHttpResponse::not_found(
"No AWS Marketplace subscription found for this organization",
);
}
Err(e) => {
log::error!("[AWS SAAS] Database error: {}", e);
return MetaHttpResponse::internal_error("Failed to check subscription status");
}
};
// If customer_id is provided in query, verify it matches the org's subscription
if let Some(customer_id) = query.get("customer_id") {
if *customer_id != billing.customer_identifier {
MetaHttpResponse::bad_request(
"The provided customer_id does not match this organization's subscription",
);
}
}
// If customer_id is not provided, skip verification and proceed (it's optional)
// Build response based on status
let response = aws_mp_db::build_activation_status_response(&billing);
MetaHttpResponse::json(response)
}
@@ -0,0 +1,149 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use axum::{extract::Path, response::Response};
use config::meta::user::UserRole;
use o2_enterprise::enterprise::cloud::billings;
use serde::{Deserialize, Serialize};
use crate::{
common::{meta::http::HttpResponse as MetaHttpResponse, utils::auth::UserEmail},
handler::http::extractors::Headers,
service::{organization, users},
};
/// Request payload for linking Azure Marketplace subscription
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct LinkSubscriptionRequest {
pub token: String,
}
/// Response from link-subscription endpoint
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct LinkSubscriptionResponse {
pub success: bool,
pub customer_identifier: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
/// LinkAzureMarketplaceSubscription
#[utoipa::path(
post,
path = "/{org_id}/azure-marketplace/link-subscription",
context_path = "/api",
tag = "Azure Marketplace",
operation_id = "LinkAzureMarketplaceSubscription",
summary = "Link Azure Marketplace subscription to organization",
description = "Processes the token provided by azure and links the subscription to organization",
security(
("Authorization" = [])
),
request_body(content = LinkSubscriptionRequest),
responses(
(status = 200, description = "Success", content_type = "application/json", body = LinkSubscriptionResponse),
(status = 400, description = "Bad Request (token expired, invalid)", content_type = "application/json", body = ()),
(status = 403, description = "Forbidden (not admin)", content_type = "application/json", body = ()),
(status = 409, description = "Conflict (already subscribed)", content_type = "application/json", body = ()),
(status = 500, description = "Internal Server Error", content_type = "application/json", body = ()),
),
)]
pub async fn link_subscription(
Path(org_id): Path<String>,
Headers(user_email): Headers<UserEmail>,
axum::Json(req): axum::Json<LinkSubscriptionRequest>,
) -> Response {
let email = user_email.user_id.as_str();
let org_id = org_id.as_str();
// 1. Validate organization exists
let _org = match organization::get_org(org_id).await {
Some(org) => org,
None => {
return MetaHttpResponse::not_found("Organization not found");
}
};
// 2. Verify user is admin of the organization
let user = match users::get_user(Some(org_id), email).await {
Some(u) => u,
None => {
return MetaHttpResponse::forbidden(
"User not found or not authorized for this organization",
);
}
};
// Only admins and root users can link Azure Marketplace subscriptions
if user.role != UserRole::Admin && user.role != UserRole::Root {
return MetaHttpResponse::forbidden(
"Only organization administrators can link Azure Marketplace subscriptions",
);
}
// 3. Check if org is free tier (cannot link paid orgs)
let subscription = match billings::get_billing_by_org_id(org_id).await {
Ok(v) => v,
Err(e) => {
log::error!("[AZURE SAAS] Error checking free trial status: {}", e);
return MetaHttpResponse::internal_error("Failed to check organization status");
}
};
if let Some(sub) = subscription {
if !sub.subscription_type.is_free_sub() {
return MetaHttpResponse::bad_request(
"Only free tier organizations can be linked to Azure Marketplace",
);
}
}
let req_id = config::ider::generate_trace_id();
log::info!(
"[AZURE SAAS] Link subscription request: org_id={}, user={}, req_id={}",
org_id,
email,
req_id
);
let res = match billings::azure_utils::process_token(&req.token, &req_id).await {
Ok(r) => r,
Err(e) => {
log::info!("[AZURE SAAS] Error processing token for org {org_id} user {email} : {e}");
return MetaHttpResponse::bad_request(e);
}
};
let sub_id = res.id.clone();
if let Err(e) = billings::azure_utils::activate_subscription(&res, &req_id).await {
log::error!(
"[AZURE SAAS] Error activating subscription for org {org_id} user {email} sub_id: {sub_id} : {e}",
);
return MetaHttpResponse::bad_request(e);
}
if let Err(e) = billings::azure_utils::save_subscription(org_id, email, res, &req_id).await {
log::error!(
"[AZURE SAAS] Error saving subscription for org {org_id} user {email} sub_id: {sub_id} : {e}",
);
MetaHttpResponse::bad_request(e);
}
log::info!(
"subscription successfully linked and activated org_id={org_id}, email={email}, req_id={req_id}"
);
MetaHttpResponse::created("successfully linked azure subscription")
}
+58 -5
View File
@@ -161,7 +161,6 @@ pub async fn process_session_detail(
Some(org) => org,
};
log::debug!("handling checkout session detail");
match o2_cloud_billings::process_checkout_session_details(
&org_id,
&query.session_id,
@@ -169,7 +168,10 @@ pub async fn process_session_detail(
)
.await
{
Err(e) => e.into_http_response(),
Err(e) => {
log::info!("checkout session errored : {e}");
e.into_http_response()
}
Ok(()) => {
let redirect_url = format!(
"{}/web/billings/plans?org_identifier={}",
@@ -212,9 +214,8 @@ pub async fn process_session_detail(
stream_name: None,
})
.await;
RedirectResponseBuilder::new(&redirect_url)
.build()
.redirect_http()
let res = RedirectResponseBuilder::new(&redirect_url).build();
res.redirect_http()
}
}
}
@@ -463,3 +464,55 @@ pub async fn handle_stripe_event(headers: HeaderMap, payload: axum::body::Bytes)
Err(err) => err.into_http_response(),
}
}
/// StripeWebhookEvent
#[utoipa::path(
post,
path="/webhook/azure",
context_path = "/webhook",
tag = "Billings",
summary = "Handle Azure webhook events",
description = "Processes webhook events from Azure for subscription changes and payments",
request_body(content = String, description = "Raw Azure webhook payload"),
responses(
(status = 200, description="Status OK", content_type = "application/json", body = ())
)
)]
pub async fn handle_azure_event(headers: HeaderMap, payload: axum::body::Bytes) -> Response {
log::info!("Azure Saas webhook received");
let Some(auth_header) = headers.get("Authorization").and_then(|v| v.to_str().ok()) else {
return o2_cloud_billings::BillingError::AzureWebhookError("invalid auth header".into())
.into_http_response();
};
match o2_cloud_billings::azure_utils::handle_azure_wh(auth_header, &payload).await {
Ok(_) => {
log::info!("successfully handled azure saas webhook");
MetaHttpResponse::json(json::json!({
"status": "success"
}))
}
Err(e) => {
log::error!("error while handling azure saas webhook : {e}");
o2_cloud_billings::BillingError::AzureWebhookError("internal error".into())
.into_http_response()
}
}
// TODO: send event for this when validated
// for (org_id, sub_type) in orgs {
// let org = match organization::get_org(&org_id).await {
// None => continue,
// Some(org) => org,
// };
// enqueue_cloud_event(CloudEvent {
// org_id: org.identifier.clone(),
// org_name: org.name.clone(),
// org_type: org.org_type.clone(),
// user: None,
// event: EventType::SubscriptionDeleted,
// subscription_type: Some(sub_type.to_string()),
// stream_name: None,
// })
// .await;
}
+2
View File
@@ -18,6 +18,8 @@ use o2_enterprise::enterprise::cloud::billings::{self as o2_cloud_billings};
use crate::common::meta::http::HttpResponse as MetaHttpResponse;
pub mod aws_marketplace;
pub mod azure_marketplace;
pub mod billings;
pub mod marketing;
pub mod org_usage;
+23 -1
View File
@@ -390,7 +390,17 @@ pub fn basic_routes() -> Router {
{
router = router.nest(
"/webhook",
Router::new().route("/stripe", post(cloud::billings::handle_stripe_event)),
Router::new()
.route("/stripe", post(cloud::billings::handle_stripe_event))
.route("/azure", post(cloud::billings::handle_azure_event)),
);
// AWS Marketplace registration endpoint - receives POST from AWS Marketplace
// Must be publicly accessible (no auth) as users haven't logged in yet
// Using /marketplace path to avoid conflict with authenticated /api scope
router = router.route(
"/marketplace/aws/register",
post(cloud::aws_marketplace::aws_marketplace_register),
);
}
@@ -823,6 +833,18 @@ pub fn service_routes() -> Router {
.route(
"/{org_id}/extend_trial_period",
put(organization::org::extend_trial_period),
)
.route(
"/{org_id}/aws-marketplace/link-subscription",
post(cloud::aws_marketplace::link_subscription),
)
.route(
"/{org_id}/aws-marketplace/activation-status",
get(cloud::aws_marketplace::activation_status),
)
.route(
"/{org_id}/azure-marketplace/link-subscription",
post(cloud::azure_marketplace::link_subscription),
);
}
+67 -2
View File
@@ -80,6 +80,62 @@ async fn enforce_usage_stream_retention() {
}
}
#[cfg(feature = "cloud")]
async fn get_metering_lock() -> Result<Option<()>, infra::errors::Error> {
if !LOCAL_NODE.is_alert_manager() {
return Ok(None);
}
use infra::{cluster::get_node_by_uuid, dist_lock};
let db = infra::db::get_db().await;
let node = db
.get("/cloud/metering/node")
.await
.ok()
.unwrap_or_default();
let node = String::from_utf8_lossy(&node);
if !node.is_empty() && LOCAL_NODE.uuid.ne(&node) && get_node_by_uuid(&node).await.is_some() {
log::info!("[o2::ENT] metering is locked by node {node}");
return Ok(None); // other node is processing
}
if node.is_empty() || LOCAL_NODE.uuid.ne(&node) {
let locker = infra::dist_lock::lock("/cloud/metering/node", 0).await?;
// check the working node again, maybe other node locked it first
let node = db
.get("/cloud/metering/node")
.await
.ok()
.unwrap_or_default();
let node = String::from_utf8_lossy(&node);
if !node.is_empty() && LOCAL_NODE.uuid.ne(&node) && get_node_by_uuid(&node).await.is_some()
{
dist_lock::unlock(&locker).await?;
return Ok(None); // other node is processing
}
// set to current node
let ret = db
.put(
"/cloud/metering/node",
LOCAL_NODE.uuid.clone().into(),
infra::db::NO_NEED_WATCH,
None,
)
.await;
// Check db.put result before releasing the lock to ensure consistent state
if let Err(e) = ret {
dist_lock::unlock(&locker).await?;
drop(locker);
return Err(e);
}
dist_lock::unlock(&locker).await?;
log::info!("[o2::ENT] Metering lock acquired");
drop(locker);
}
Ok(Some(()))
}
pub async fn init() -> Result<(), anyhow::Error> {
let email_regex = Regex::new(
r"^([a-z0-9_+]([a-z0-9_+.-]*[a-z0-9_+])?)@([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6})",
@@ -393,9 +449,18 @@ pub async fn init() -> Result<(), anyhow::Error> {
.expect("cloud ofga migrations failed");
use crate::service::self_reporting::{ingest_data_retention_usages, search::get_usage};
o2_enterprise::enterprise::metering::init(get_usage, ingest_data_retention_usages)
o2_enterprise::enterprise::metering::init(
get_metering_lock,
get_usage,
ingest_data_retention_usages,
)
.await
.expect("cloud usage metering job init failed");
// Initialize AWS Marketplace SQS notification polling
o2_enterprise::enterprise::aws_marketplace::init()
.await
.expect("cloud usage metering job init failed");
.expect("AWS Marketplace integration init failed");
// run these cloud jobs only in alert manager
if LOCAL_NODE.is_alert_manager() {
+45
View File
@@ -346,6 +346,21 @@ async fn handle_alert_triggers(
&trigger.module_key
);
}
if let Err(e) = db::scheduler::delete(
&trigger.org,
db::scheduler::TriggerModule::Alert,
&trigger.module_key,
)
.await
{
log::error!(
"[SCHEDULER trace_id {scheduler_trace_id}] Failed to remove alert from scheduled_jobs due to trial expiry: {}/{} : {e}",
&trigger.org,
&trigger.module_key
);
}
return Ok(());
}
}
@@ -1213,6 +1228,21 @@ async fn handle_report_triggers(
report_name
);
}
if let Err(e) = db::scheduler::delete(
&trigger.org,
db::scheduler::TriggerModule::Report,
&trigger.module_key,
)
.await
{
log::error!(
"[SCHEDULER trace_id {scheduler_trace_id}] Failed to remove report from scheduled_jobs due to trial expiry: {}/{} : {e}",
&trigger.org,
report_name
);
}
return Ok(());
}
}
@@ -1518,6 +1548,21 @@ async fn handle_derived_stream_triggers(
pipeline.name
);
}
if let Err(e) = db::scheduler::delete(
&trigger.org,
db::scheduler::TriggerModule::DerivedStream,
&trigger.module_key,
)
.await
{
log::error!(
"[SCHEDULER trace_id {scheduler_trace_id}] Failed to remove pipeline from scheduled_jobs due to trial expiry: {}/{} : {e}",
&trigger.org,
pipeline.name
);
}
return Ok(());
}
}
+40 -13
View File
@@ -343,15 +343,23 @@ pub async fn create_org(
#[cfg(feature = "cloud")]
{
let o2cfg = o2_enterprise::enterprise::common::config::get_config();
let orgs = list_orgs_by_user(user_email).await?;
let mut free_org_count = 0;
for org in orgs {
let billing = get_billing_by_org_id(&org.identifier).await?;
if billing.is_none() {
return Err(anyhow::anyhow!(
"A user cannot be part of multiple free accounts"
));
let billings = get_billing_by_org_id(&org.identifier).await?;
if billings.is_none() {
free_org_count += 1;
}
}
// if we allow creating a new org, the free org count would be max+1
// hence check of >= rather than >
if free_org_count >= o2cfg.cloud.max_free_orgs_allowed {
return Err(anyhow::anyhow!(
"A user cannot be part of more than {} free organizations.",
o2cfg.cloud.max_free_orgs_allowed
));
}
}
org.name = org.name.trim().to_owned();
@@ -735,14 +743,23 @@ pub async fn generate_invitation(
// irrespective of what other orgs invitees are part of.
// if it is a free org, we must check that the orgs invitee is already part
// of are all paid, as one user cannot be part of more than one free org.
let o2cfg = o2_enterprise::enterprise::common::config::get_config();
let mut free_org_count = 0;
let invitee_orgs = list_orgs_by_user(invitee).await?;
for org in invitee_orgs {
if get_billing_by_org_id(&org.identifier).await?.is_none() {
return Err(anyhow::anyhow!(
"Invitee {invitee} is already part of another free org, cannot be invited in this org"
));
let billings = get_billing_by_org_id(&org.identifier).await?;
if billings.is_none() {
free_org_count += 1;
}
}
// if we allow the invite the potential count would be max+1 (after accepting)
// hence >= instead of >
if free_org_count >= o2cfg.cloud.max_free_orgs_allowed {
return Err(anyhow::anyhow!(
"Invitee {invitee} is already part of {} free organizations, cannot be invited in this organization.",
o2cfg.cloud.max_free_orgs_allowed
));
}
}
}
if let Some(org) = get_org(org_id).await {
@@ -823,14 +840,24 @@ pub async fn accept_invitation(user_email: &str, invite_token: &str) -> Result<(
// if the org user is joining is paid, no issues, we can just let them join
// if it is a free org, we must check that the orgs the joining user is already part
// of are all paid, as one user cannot be part of more than one free org.
let o2cfg = o2_enterprise::enterprise::common::config::get_config();
let mut free_org_count = 0;
let user_orgs = list_orgs_by_user(user_email).await?;
for org in user_orgs {
if get_billing_by_org_id(&org.identifier).await?.is_none() {
return Err(anyhow::anyhow!(
"User is already a part of a free organization. A user cannot join multiple free orgs."
));
let billings = get_billing_by_org_id(&org.identifier).await?;
if billings.is_none() {
free_org_count += 1;
}
}
// if we allow the invite the potential count would be max+1 (after accepting)
// hence >= instead of >
if free_org_count >= o2cfg.cloud.max_free_orgs_allowed {
return Err(anyhow::anyhow!(
"User is already a part of {} free organization. A user cannot join more than {} free organizations.",
o2cfg.cloud.max_free_orgs_allowed,
o2cfg.cloud.max_free_orgs_allowed
));
}
}
// Check if user is already part of the org
@@ -49,13 +49,28 @@ vi.mock("quasar", async (importOriginal) => {
};
});
// Mock billing service - using factory function to avoid hoisting issues
vi.mock("@/services/billings", () => ({
default: {
list_subscription: vi.fn()
}
}));
// Import after mocking
import BillingService from "@/services/billings";
describe("Billing Component", () => {
let wrapper: any = null;
beforeEach(() => {
beforeEach(async () => {
// Reset mocks
vi.clearAllMocks();
// Mock billing service response
(BillingService.list_subscription as any).mockResolvedValue({
data: { provider: "stripe" }
});
// Reset router state
mockRouter.currentRoute.value.name = "billings";
mockRouter.currentRoute.value.query = {
@@ -90,6 +105,9 @@ describe("Billing Component", () => {
}
},
});
// Wait for onMounted to complete
await wrapper.vm.$nextTick();
});
afterEach(() => {
@@ -108,7 +126,7 @@ describe("Billing Component", () => {
});
it("should initialize with correct data", () => {
expect(wrapper.vm.billingtab).toBe("plans"); // Should be set to "plans" in onMounted
expect(wrapper.vm.billingtab).toBe("plans"); // Should be set to "plans" in onMounted after fetchBillingInfo
expect(wrapper.vm.usageDataType).toBe("gb");
expect(wrapper.vm.splitterModel).toBe(220);
expect(wrapper.vm.usageDate).toBe("30days");
@@ -144,7 +162,8 @@ describe("Billing Component", () => {
});
describe("onMounted Lifecycle Hook", () => {
it("should redirect to plans when route is billings", () => {
it("should redirect to plans when route is billings", async () => {
// The wrapper was already mounted in beforeEach and onMounted completed
expect(mockRouter.push).toHaveBeenCalledWith({
path: "/billings/plans",
query: { org_identifier: store.state.selectedOrganization.identifier }
@@ -152,10 +171,19 @@ describe("Billing Component", () => {
expect(wrapper.vm.billingtab).toBe("plans");
});
it("should redirect to plans when route is plans", () => {
it("should redirect to plans when route is plans", async () => {
// Reset router state and clear mocks before mounting
mockRouter.currentRoute.value.name = "plans";
mockRouter.push.mockClear();
mockRouter.currentRoute.value.query = {
data_type: "gb",
usage_date: "30days"
};
// Mock billing service response for this test
(BillingService.list_subscription as any).mockResolvedValue({
data: { provider: "stripe" }
});
const testWrapper = mount(Billing, {
global: {
plugins: [i18n],
@@ -181,7 +209,12 @@ describe("Billing Component", () => {
}
},
});
// Wait for onMounted to complete (including fetchBillingInfo)
await testWrapper.vm.$nextTick();
await testWrapper.vm.$nextTick();
// The router.push should have been called during onMounted
expect(mockRouter.push).toHaveBeenCalledWith({
path: "/billings/plans",
query: { org_identifier: store.state.selectedOrganization.identifier }
@@ -190,10 +223,10 @@ describe("Billing Component", () => {
testWrapper.unmount();
});
it("should not redirect when route is usage", () => {
it("should not redirect when route is usage", async () => {
mockRouter.currentRoute.value.name = "usage";
mockRouter.push.mockClear();
const testWrapper = mount(Billing, {
global: {
plugins: [i18n],
@@ -219,7 +252,10 @@ describe("Billing Component", () => {
}
},
});
// Wait for onMounted to complete
await testWrapper.vm.$nextTick();
expect(mockRouter.push).not.toHaveBeenCalled();
testWrapper.unmount();
});
@@ -445,9 +481,9 @@ describe("Billing Component", () => {
});
describe("Query Parameter Initialization", () => {
it("should initialize usageDataType from router query", () => {
it("should initialize usageDataType from router query", async () => {
mockRouter.currentRoute.value.query.data_type = "mb";
const testWrapper = mount(Billing, {
global: {
plugins: [i18n],
@@ -473,14 +509,16 @@ describe("Billing Component", () => {
}
},
});
await testWrapper.vm.$nextTick();
expect(testWrapper.vm.usageDataType).toBe("mb");
testWrapper.unmount();
});
it("should initialize usageDate from router query", () => {
it("should initialize usageDate from router query", async () => {
mockRouter.currentRoute.value.query.usage_date = "3months";
const testWrapper = mount(Billing, {
global: {
plugins: [i18n],
@@ -506,14 +544,16 @@ describe("Billing Component", () => {
}
},
});
await testWrapper.vm.$nextTick();
expect(testWrapper.vm.usageDate).toBe("3months");
testWrapper.unmount();
});
it("should use default values when query parameters are missing", () => {
it("should use default values when query parameters are missing", async () => {
mockRouter.currentRoute.value.query = {};
const testWrapper = mount(Billing, {
global: {
plugins: [i18n],
@@ -539,7 +579,9 @@ describe("Billing Component", () => {
}
},
});
await testWrapper.vm.$nextTick();
expect(testWrapper.vm.usageDataType).toBe("gb");
expect(testWrapper.vm.usageDate).toBe("30days");
testWrapper.unmount();
@@ -62,45 +62,46 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
vertical
>
<q-route-tab
exact
name="plans"
:to="
'/billings/plans?org_identifier=' +
store.state.selectedOrganization.identifier
"
:icon="'img:' + getImageURL('images/common/plan_icon.svg')"
:label="t('billing.plansLabel')"
content-class="tab_content"
/>
<q-route-tab
exact
default
name="usage"
:to="
'/billings/usage?org_identifier=' +
store.state.selectedOrganization.identifier +
'&usage_date=' +
usageDate +
'&data_type=' +
usageDataType
"
:icon="'img:' + getImageURL('images/common/usage_icon.svg')"
:label="t('billing.usageLabel')"
content-class="tab_content"
/>
<q-route-tab
exact
name="invoice_history"
:to="
'/billings/invoice_history?org_identifier=' +
store.state.selectedOrganization.identifier
"
:icon="'img:' + getImageURL('images/common/invoice_icon.svg')"
:label="t('billing.invoiceHistoryLabel')"
content-class="tab_content"
/>
</q-tabs>
<q-route-tab
exact
name="plans"
:to="
'/billings/plans?org_identifier=' +
store.state.selectedOrganization.identifier
"
:icon="'img:' + getImageURL('images/common/plan_icon.svg')"
:label="t('billing.plansLabel')"
content-class="tab_content"
/>
<q-route-tab
exact
default
name="usage"
:to="
'/billings/usage?org_identifier=' +
store.state.selectedOrganization.identifier +
'&usage_date=' +
usageDate +
'&data_type=' +
usageDataType
"
:icon="'img:' + getImageURL('images/common/usage_icon.svg')"
:label="t('billing.usageLabel')"
content-class="tab_content"
/>
<q-route-tab
v-if="showInvoiceTab"
exact
name="invoice_history"
:to="
'/billings/invoice_history?org_identifier=' +
store.state.selectedOrganization.identifier
"
:icon="'img:' + getImageURL('images/common/invoice_icon.svg')"
:label="t('billing.invoiceHistoryLabel')"
content-class="tab_content"
/>
</q-tabs>
<!-- <q-btn
data-test="logs-search-field-list-collapse-btn"
:icon="showSidebar ? 'chevron_left' : 'chevron_right'"
@@ -140,6 +141,7 @@ import ConfirmDialog from "@/components/ConfirmDialog.vue";
import Usage from "./usage.vue";
import { getImageURL } from "@/utils/zincutils";
import AppTabs from "@/components/common/AppTabs.vue";
import BillingService from "@/services/billings";
export default defineComponent({
name: "PageIngestion",
@@ -154,6 +156,28 @@ export default defineComponent({
const showSidebar = ref(true);
const lastSplitterPosition = ref(200);
const splitterModel = ref(220);
const billingProvider = ref(""); // empty until loaded
const billingInfoLoaded = ref(false);
// Fetch billing info to determine provider
const fetchBillingInfo = async () => {
try {
const res = await BillingService.list_subscription(
store.state.selectedOrganization.identifier
);
billingProvider.value = res.data?.provider || "";
} catch (e) {
console.error("Failed to fetch billing info:", e);
billingProvider.value = "";
} finally {
billingInfoLoaded.value = true;
}
};
// Check if invoice tab should be shown (only for Stripe, and only after loading)
const showInvoiceTab = computed(() => {
return billingInfoLoaded.value && billingProvider.value === "stripe";
});
const collapseSidebar = () => {
showSidebar.value = !showSidebar.value;
if (showSidebar.value) {
@@ -164,7 +188,9 @@ export default defineComponent({
}
};
onMounted(() => {
onMounted(async () => {
// Fetch billing info to determine provider type
await fetchBillingInfo();
if (router.currentRoute.value.name == "billings" || router.currentRoute.value.name == "plans") {
billingtab.value = "plans";
router.push({ path: "/billings/plans", query: { org_identifier: store.state.selectedOrganization.identifier } });
@@ -226,7 +252,7 @@ export default defineComponent({
splitterModel,
headerBasedOnRoute,
options: [
{label: "30 Days", value: "30days"},
{label: "30 Days", value: "30days"},
{label: "60 Days", value: "60days"},
{label: "3 Months", value: "3months"},
{label: "6 Months", value: "6months"}],
@@ -239,6 +265,8 @@ export default defineComponent({
collapseSidebar,
showSidebar,
lastSplitterPosition,
showInvoiceTab,
billingProvider,
};
},
});
@@ -42,7 +42,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script lang="ts">
// @ts-ignore
import { defineComponent, ref } from "vue";
import { defineComponent, ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
@@ -50,6 +50,8 @@ import { useQuasar } from "quasar";
import config from "@/aws-exports";
import { siteURL } from "@/constants/config";
import { getDueDays } from "@/utils/zincutils";
import BillingService from "@/services/billings";
export default defineComponent({
name: "TrialPeriod",
@@ -76,8 +78,28 @@ export default defineComponent({
const q = useQuasar();
const router: any = useRouter();
const showTrialPeriodMsg = ref((Object.hasOwn(store.state.organizationData.organizationSettings, "free_trial_expiry") && store.state.organizationData.organizationSettings.free_trial_expiry != "" && store.state.organizationData.organizationSettings.free_trial_expiry != null) ? true : false);
const hasTrialExpiry = Object.hasOwn(store.state.organizationData.organizationSettings, "free_trial_expiry")
&& store.state.organizationData.organizationSettings.free_trial_expiry != ""
&& store.state.organizationData.organizationSettings.free_trial_expiry != null;
const showTrialPeriodMsg = ref(hasTrialExpiry);
// Check if org is on AWS billing - don't show trial message for AWS orgs
onMounted(async () => {
try {
const res = await BillingService.list_subscription(
store.state.selectedOrganization.identifier
);
if (res.data?.provider === "aws") {
// AWS billing - don't show trial period message
showTrialPeriodMsg.value = false;
}
} catch (e) {
// If fetch fails, keep the default behavior
console.error("Failed to fetch billing info:", e);
}
});
const redirectBilling = () => {
router.push('/billings/plans/')
};
@@ -43,6 +43,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div v-else class="row q-gutter-md justify-center">
<pro-plan
:planType="planType"
:billingProvider="billingProvider"
@update:proSubscription="onLoadSubscription(config.paidPlan)"
@update:cancelSubscription="onUnsubscribe"
></pro-plan>
@@ -137,6 +138,7 @@ export default defineComponent({
try{
const res = await BillingService.list_subscription(this.store.state.selectedOrganization.identifier);
this.currentPlanDetail = res.data;
this.billingProvider = res.data.provider || "";
if (res.data.subscription_type !== "") {
if (res.data.subscription_type == config.paidPlan) {
@@ -152,7 +154,8 @@ export default defineComponent({
useLocalOrganization(localOrg.value);
this.store.dispatch("setSelectedOrganization", localOrg.value);
}
} else {
} else if (this.billingProvider === "" || this.billingProvider === "stripe") {
// Only show subscribe prompt for Stripe orgs without subscription
this.$q.notify({
type: "warning",
message: "Please subscribe to one of the plan.",
@@ -193,6 +196,7 @@ export default defineComponent({
const listSubscriptionResponse: any = ref({});
const proLoading: any = ref(false);
const currentPlanDetail = ref();
const billingProvider = ref("");
const retrieveHostedPage = () => {
BillingService.retrieve_hosted_page(
@@ -221,6 +225,7 @@ export default defineComponent({
retrieveHostedPage,
proLoading,
currentPlanDetail,
billingProvider,
};
},
});
@@ -61,8 +61,34 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<div class="row justify-between q-pa-md">
<!-- AWS Marketplace billing - show managed externally message -->
<div v-if="billingProvider === 'aws'" class="full-width text-center">
<q-chip
color="green-2"
text-color="green-10"
icon="check_circle"
label="Managed via AWS Marketplace"
class="q-px-md q-py-sm"
/>
<div class="text-caption text-grey-7 q-mt-sm">
Billing is handled through your AWS account
</div>
</div>
<div v-else-if="billingProvider === 'azure'" class="full-width text-center">
<q-chip
color="green-2"
text-color="green-10"
icon="check_circle"
label="Managed via Azure Marketplace"
class="q-px-md q-py-sm"
/>
<div class="text-caption text-grey-7 q-mt-sm">
Billing is handled through your Azure account
</div>
</div>
<!-- Stripe billing - show subscribe/manage buttons -->
<q-btn
v-if="planType == planName"
v-else-if="planType == planName"
:label="btnCancelSubscription"
text-color="black"
class="full-width bg-grey-4 text-bold text-capitalize text-subtitle1"
@@ -87,7 +113,7 @@ import { useI18n } from "vue-i18n";
export default defineComponent({
name: "proPlan",
props: ["planType"],
props: ["planType", "billingProvider"],
setup(props, { emit }) {
const { t } = useI18n();
const planName = "pay-as-you-go";
+31 -1
View File
@@ -17,8 +17,38 @@ import Billing from "@/enterprise/components/billings/Billing.vue";
import Plans from "@/enterprise/components/billings/plans.vue";
import InvoiceHistory from "@/enterprise/components/billings/invoiceHistory.vue";
import Usage from "@/enterprise/components/billings/usage.vue";
import AzureMarketplaceSetup from "@/views/AzureMarketplaceSetup.vue";
import AwsMarketplaceSetup from "@/views/AwsMarketplaceSetup.vue";
const useEnvRoutes = () => {
const parentRoutes: any = [];
// Note: AWS Marketplace registration is handled by backend at POST /api/aws-marketplace/register
// The backend sets a cookie and redirects to Dex login
const parentRoutes: any = [
{
// Post-login setup page for org selection/creation
path: "/marketplace/aws/setup",
name: "awsMarketplaceSetup",
component: AwsMarketplaceSetup,
meta: {
title: "AWS Marketplace Setup",
requiresAuth: true,
},
},
{
// Entry point from Azure Marketplace - saves token and redirects to login
path: "/marketplace/azure/register",
name: "azureMarketplaceRegister",
component: AzureMarketplaceSetup,
beforeEnter: (to: any, from: any, next: any) => {
const token = to.query.token;
if (token) {
// Save token for after login - Login.vue will check this
sessionStorage.setItem("azure_marketplace_token", token);
}
next();
},
},
];
const homeChildRoutes = [
{
+35
View File
@@ -2684,5 +2684,40 @@
"clearChatTitle": "Chat löschen",
"clearChatMessage": "Bist du sicher, dass du das Gespräch beenden willst?",
"copiedToClipboard": "In die Zwischenablage kopiert"
},
"awsMarketplace": {
"title": "AWS Marketplace-Registrierung",
"subtitle": "Vervollständigen Sie die Einrichtung Ihres OpenObserve-Abonnements",
"invalidLink": "Ungültiger Registrierungslink",
"invalidLinkDescription": "Dieser Registrierungslink ist ungültig oder abgelaufen. Bitte abonnieren Sie erneut im AWS Marketplace.",
"goToMarketplace": "Gehen Sie zum AWS Marketplace",
"newUser": "Neu bei OpenObserve?",
"newUserDescription": "Neues Konto und Organisation erstellen",
"createAccount": "Konto erstellen",
"existingUser": "Hast du schon ein Konto?",
"existingUserDescription": "Melden Sie sich an, um eine neue Organisation mit AWS-Abrechnung zu erstellen",
"login": "Einloggen",
"loggedInAs": "Eingeloggt als",
"createNewOrg": "Neue Organisation erstellen",
"createNewOrgDescription": "Erstellen Sie eine neue Organisation mit AWS Marketplace-Abrechnung",
"orgName": "Name der Organisation",
"createOrganization": "Organisation erstellen",
"linkToExisting": "Link zur bestehenden Organisation",
"linkToExistingDescription": "Verknüpfen Sie die AWS-Abrechnung mit einer Ihrer Organisationen mit kostenlosem Nutzungskontingent",
"selectOrganization": "Wählen Sie Organisation",
"linkAwsBilling": "AWS-Abrechnung verknüpfen",
"settingUp": "Ihr Abonnement wird eingerichtet...",
"pleaseWait": "Bitte warten Sie, bis wir Ihr Konto konfiguriert haben.",
"waitingConfirmation": "Warten auf die AWS-Bestätigung",
"waitingDescription": "Ihr Abonnement wird aktiviert. Dies dauert normalerweise weniger als eine Minute.",
"customerId": "Kunden-ID",
"subscriptionActivated": "Abonnement aktiviert!",
"activatedDescription": "Ihr AWS Marketplace-Abonnement ist jetzt aktiv.",
"goToDashboard": "Gehe zum Dashboard",
"paymentFailed": "Zahlung fehlgeschlagen",
"paymentFailedDescription": "Es gab ein Problem mit Ihrer AWS Marketplace-Zahlung. Bitte überprüfen Sie Ihr AWS-Konto oder wenden Sie sich an den AWS-Support.",
"contactSupport": "Support kontaktieren",
"tryAgain": "Versuchen Sie es erneut",
"activationTimeout": "Aktivierungs-Timeout. Bitte wenden Sie sich an den Support, falls das Problem weiterhin besteht."
}
}
+35
View File
@@ -1736,6 +1736,41 @@
"contactSupport": "Contact Support",
"totalUsage": "Total Usage"
},
"awsMarketplace": {
"title": "AWS Marketplace Registration",
"subtitle": "Complete your OpenObserve subscription setup",
"invalidLink": "Invalid Registration Link",
"invalidLinkDescription": "This registration link is invalid or has expired. Please subscribe again from AWS Marketplace.",
"goToMarketplace": "Go to AWS Marketplace",
"newUser": "New to OpenObserve?",
"newUserDescription": "Create a new account and organization",
"createAccount": "Create Account",
"existingUser": "Already have an account?",
"existingUserDescription": "Login to create a new organization with AWS billing",
"login": "Login",
"loggedInAs": "Logged in as",
"createNewOrg": "Create New Organization",
"createNewOrgDescription": "Create a new organization with AWS Marketplace billing",
"orgName": "Organization Name",
"createOrganization": "Create Organization",
"linkToExisting": "Link to Existing Organization",
"linkToExistingDescription": "Link AWS billing to one of your free-tier organizations",
"selectOrganization": "Select Organization",
"linkAwsBilling": "Link AWS Billing",
"settingUp": "Setting up your subscription...",
"pleaseWait": "Please wait while we configure your account.",
"waitingConfirmation": "Waiting for AWS Confirmation",
"waitingDescription": "Your subscription is being activated. This usually takes less than a minute.",
"customerId": "Customer ID",
"subscriptionActivated": "Subscription Activated!",
"activatedDescription": "Your AWS Marketplace subscription is now active.",
"goToDashboard": "Go to Dashboard",
"paymentFailed": "Payment Failed",
"paymentFailedDescription": "There was an issue with your AWS Marketplace payment. Please check your AWS account or contact AWS support.",
"contactSupport": "Contact Support",
"tryAgain": "Try Again",
"activationTimeout": "Activation timeout. Please contact support if the issue persists."
},
"message": {
"ServerInternalError": "Error: Something went wrong. Internal server error.",
"SearchSQLNotValid": "Error: Invalid SQL.",
+35
View File
@@ -2684,5 +2684,40 @@
"clearChatTitle": "Borrar chat",
"clearChatMessage": "¿Estás seguro de que quieres borrar esta conversación?",
"copiedToClipboard": "Copiado al portapapeles"
},
"awsMarketplace": {
"title": "Registro en AWS Marketplace",
"subtitle": "Complete la configuración de su suscripción a OpenObserve",
"invalidLink": "Enlace de registro no válido",
"invalidLinkDescription": "Este enlace de registro no es válido o ha caducado. Vuelva a suscribirse desde AWS Marketplace.",
"goToMarketplace": "Ir a AWS Marketplace",
"newUser": "¿Es nuevo en OpenObserve?",
"newUserDescription": "Crear una nueva cuenta y organización",
"createAccount": "Crear cuenta",
"existingUser": "¿Ya tienes una cuenta?",
"existingUserDescription": "Inicie sesión para crear una nueva organización con AWS Billing",
"login": "Iniciar sesión",
"loggedInAs": "Ha iniciado sesión como",
"createNewOrg": "Crear nueva organización",
"createNewOrgDescription": "Cree una nueva organización con la facturación de AWS Marketplace",
"orgName": "Nombre de la organización",
"createOrganization": "Crear organización",
"linkToExisting": "Enlace a la organización existente",
"linkToExistingDescription": "Vincula la facturación de AWS a una de tus organizaciones de nivel gratuito",
"selectOrganization": "Seleccione la organización",
"linkAwsBilling": "Vincular AWS Billing",
"settingUp": "Configurando tu suscripción...",
"pleaseWait": "Espere mientras configuramos su cuenta.",
"waitingConfirmation": "Esperando la confirmación de AWS",
"waitingDescription": "Se está activando tu suscripción. Esto suele tardar menos de un minuto.",
"customerId": "ID de cliente",
"subscriptionActivated": "¡Suscripción activada!",
"activatedDescription": "Su suscripción a AWS Marketplace ya está activa.",
"goToDashboard": "Ir al panel",
"paymentFailed": "Fallo en el pago",
"paymentFailedDescription": "Se ha producido un problema con el pago de AWS Marketplace. Compruebe su cuenta de AWS o póngase en contacto con el servicio de asistencia de AWS.",
"contactSupport": "Comuníquese con el soporte",
"tryAgain": "Inténtalo de nuevo",
"activationTimeout": "Tiempo de espera de activación. Ponte en contacto con el servicio de asistencia si el problema persiste."
}
}
+35
View File
@@ -2684,5 +2684,40 @@
"clearChatTitle": "Effacer le chat",
"clearChatMessage": "Êtes-vous sûr de vouloir effacer cette conversation ?",
"copiedToClipboard": "Copié dans le presse-papiers"
},
"awsMarketplace": {
"title": "Inscription à AWS Marketplace",
"subtitle": "Terminez la configuration de votre abonnement OpenObserve",
"invalidLink": "Lien d'inscription non valide",
"invalidLinkDescription": "Ce lien d'inscription n'est pas valide ou a expiré. Veuillez vous inscrire à nouveau sur AWS Marketplace.",
"goToMarketplace": "Accédez à AWS Marketplace",
"newUser": "Vous êtes nouveau sur OpenObserve ?",
"newUserDescription": "Créer un nouveau compte et une nouvelle organisation",
"createAccount": "Créer un compte",
"existingUser": "Vous avez déjà un compte ?",
"existingUserDescription": "Connectez-vous pour créer une nouvelle organisation avec AWS Billing",
"login": "Se connecter",
"loggedInAs": "Connecté en tant que",
"createNewOrg": "Créer une nouvelle organisation",
"createNewOrgDescription": "Créez une nouvelle organisation grâce à la facturation AWS Marketplace",
"orgName": "Nom de l'organisation",
"createOrganization": "Créer une organisation",
"linkToExisting": "Lien vers l'organisation existante",
"linkToExistingDescription": "Associez la facturation AWS à l'une de vos organisations de niveau gratuit",
"selectOrganization": "Sélectionnez une organisation",
"linkAwsBilling": "Lien vers AWS Billing",
"settingUp": "Configuration de votre abonnement...",
"pleaseWait": "Veuillez patienter pendant que nous configurons votre compte.",
"waitingConfirmation": "En attente de confirmation par AWS",
"waitingDescription": "Votre abonnement est en cours d'activation. Cela prend généralement moins d'une minute.",
"customerId": "ID client",
"subscriptionActivated": "Abonnement activé !",
"activatedDescription": "Votre abonnement AWS Marketplace est désormais actif.",
"goToDashboard": "Accédez au tableau de bord",
"paymentFailed": "Echec du paiement",
"paymentFailedDescription": "Un problème est survenu lors de votre paiement sur AWS Marketplace. Vérifiez votre compte AWS ou contactez le support AWS.",
"contactSupport": "Contacter le service d'assistance",
"tryAgain": "Essayez à nouveau",
"activationTimeout": "Délai d'activation. Veuillez contacter le support si le problème persiste."
}
}
+35
View File
@@ -2684,5 +2684,40 @@
"clearChatTitle": "Cancella chat",
"clearChatMessage": "Sei sicuro di voler chiarire questa conversazione?",
"copiedToClipboard": "Copiato negli appunti"
},
"awsMarketplace": {
"title": "Registrazione AWS Marketplace",
"subtitle": "Completa la configurazione dell'abbonamento OpenObserve",
"invalidLink": "Link di registrazione non valido",
"invalidLinkDescription": "Questo link di registrazione non è valido o è scaduto. Abbonati nuovamente da AWS Marketplace.",
"goToMarketplace": "Vai ad AWS Marketplace",
"newUser": "Sei nuovo su OpenObserve?",
"newUserDescription": "Crea un nuovo account e una nuova organizzazione",
"createAccount": "Crea un account",
"existingUser": "Hai già un account?",
"existingUserDescription": "Accedi per creare una nuova organizzazione con fatturazione AWS",
"login": "Accedi",
"loggedInAs": "Effettuato l'accesso come",
"createNewOrg": "Crea nuova organizzazione",
"createNewOrgDescription": "Crea una nuova organizzazione con la fatturazione di AWS Marketplace",
"orgName": "Nome dell'organizzazione",
"createOrganization": "Crea organizzazione",
"linkToExisting": "Collegamento a un'organizzazione esistente",
"linkToExistingDescription": "Collega la fatturazione AWS a una delle tue organizzazioni di livello gratuito",
"selectOrganization": "Seleziona organizzazione",
"linkAwsBilling": "Collega la fatturazione AWS",
"settingUp": "Configurazione dell'abbonamento...",
"pleaseWait": "Attendi mentre configuriamo il tuo account.",
"waitingConfirmation": "In attesa della conferma di AWS",
"waitingDescription": "L'abbonamento è in fase di attivazione. Di solito ci vuole meno di un minuto.",
"customerId": "ID cliente",
"subscriptionActivated": "Abbonamento attivato!",
"activatedDescription": "Il tuo abbonamento ad AWS Marketplace è ora attivo.",
"goToDashboard": "Vai alla dashboard",
"paymentFailed": "Pagamento non riuscito",
"paymentFailedDescription": "Si è verificato un problema con il pagamento di AWS Marketplace. Controlla il tuo account AWS o contatta l'assistenza AWS.",
"contactSupport": "Contatta l'assistenza",
"tryAgain": "Riprova",
"activationTimeout": "Timeout di attivazione. Contatta l'assistenza se il problema persiste."
}
}
+35
View File
@@ -2677,5 +2677,40 @@
"clearChatTitle": "チャットをクリア",
"clearChatMessage": "この会話をクリアしてもよろしいですか?",
"copiedToClipboard": "クリップボードにコピー"
},
"awsMarketplace": {
"title": "AWS マーケットプレイス登録",
"subtitle": "OpenObserveサブスクリプションのセットアップを完了してください",
"invalidLink": "登録リンクが無効です",
"invalidLinkDescription": "この登録リンクは無効か、有効期限が切れています。AWS Marketplace から再度購読してください。",
"goToMarketplace": "AWS マーケットプレイスに移動",
"newUser": "オープンオブザーブは初めてですか?",
"newUserDescription": "新しいアカウントと組織を作成する",
"createAccount": "アカウント作成",
"existingUser": "すでにアカウントをお持ちですか?",
"existingUserDescription": "ログインして AWS 請求で新しい組織を作成する",
"login": "ログイン",
"loggedInAs": "としてログイン",
"createNewOrg": "新しい組織を作成",
"createNewOrgDescription": "AWS Marketplace 請求を利用して新しい組織を作成する",
"orgName": "[組織名]",
"createOrganization": "組織を作成",
"linkToExisting": "既存の組織へのリンク",
"linkToExistingDescription": "AWS 請求をいずれかの無料利用枠組織にリンクする",
"selectOrganization": "組織を選択",
"linkAwsBilling": "AWS 請求のリンク",
"settingUp": "サブスクリプションをセットアップ中...",
"pleaseWait": "アカウントを設定中です。しばらくお待ちください。",
"waitingConfirmation": "AWS の確認を待っています",
"waitingDescription": "サブスクリプションはアクティベートされています。通常、この処理には 1 分もかかりません。",
"customerId": "カスタマー ID",
"subscriptionActivated": "購読がアクティベートされました!",
"activatedDescription": "お客様の AWS Marketplace サブスクリプションがアクティブになりました。",
"goToDashboard": "ダッシュボードに移動",
"paymentFailed": "支払い失敗",
"paymentFailedDescription": "お客様の AWS マーケットプレイスの支払いに問題がありました。AWS アカウントを確認するか、AWS サポートに連絡してください。",
"contactSupport": "サポートに連絡",
"tryAgain": "もう一度試す",
"activationTimeout": "アクティベーションタイムアウト。問題が解決しない場合は、サポートに連絡してください。"
}
}
+35
View File
@@ -2684,5 +2684,40 @@
"clearChatTitle": "채팅 지우기",
"clearChatMessage": "정말 이 대화를 지우고 싶으신가요?",
"copiedToClipboard": "클립보드에 복사됨"
},
"awsMarketplace": {
"title": "AWS 마켓플레이스 등록",
"subtitle": "오픈옵저브 서브스크립션 설정을 완료하세요",
"invalidLink": "잘못된 등록 링크",
"invalidLinkDescription": "이 등록 링크가 유효하지 않거나 만료되었습니다.AWS 마켓플레이스에서 다시 구독하십시오.",
"goToMarketplace": "AWS 마켓플레이스로 이동",
"newUser": "오픈옵저브를 처음 사용하시나요?",
"newUserDescription": "새 계정 및 조직 만들기",
"createAccount": "계정 생성",
"existingUser": "이미 계정이 있으신가요?",
"existingUserDescription": "로그인하여 AWS 결제로 새 조직을 생성하십시오.",
"login": "로그인",
"loggedInAs": "로 로그인",
"createNewOrg": "새 조직 생성",
"createNewOrgDescription": "AWS 마켓플레이스 결제로 새 조직 만들기",
"orgName": "조직 이름",
"createOrganization": "조직 생성",
"linkToExisting": "기존 조직에 연결",
"linkToExistingDescription": "AWS 결제를 프리 티어 조직 중 하나에 연결",
"selectOrganization": "조직 선택",
"linkAwsBilling": "AWS 결제 연결",
"settingUp": "구독 설정 중...",
"pleaseWait": "계정을 구성하는 동안 잠시 기다려 주십시오.",
"waitingConfirmation": "AWS 확인을 기다리는 중",
"waitingDescription": "구독이 활성화되고 있습니다.보통 1분도 채 걸리지 않습니다.",
"customerId": "고객 ID",
"subscriptionActivated": "구독이 활성화되었습니다!",
"activatedDescription": "이제 AWS 마켓플레이스 구독이 활성화되었습니다.",
"goToDashboard": "대시보드로 이동",
"paymentFailed": "결제 실패",
"paymentFailedDescription": "AWS 마켓플레이스 결제에 문제가 발생했습니다.AWS 계정을 확인하거나 AWS 지원에 문의하십시오.",
"contactSupport": "지원팀에 문의",
"tryAgain": "다시 시도하세요",
"activationTimeout": "활성화 제한 시간이 초과되었습니다.문제가 지속되면 지원팀에 문의하세요."
}
}
+35
View File
@@ -2684,5 +2684,40 @@
"clearChatTitle": "Chat wissen",
"clearChatMessage": "Weet je zeker dat je dit gesprek wilt beëindigen?",
"copiedToClipboard": "Gekopieerd naar klembord"
},
"awsMarketplace": {
"title": "AWS Marketplace-registratie",
"subtitle": "Voltooi de configuratie van uw OpenObserve-abonnement",
"invalidLink": "Ongeldige registratielink",
"invalidLinkDescription": "Deze registratielink is ongeldig of is verlopen. Meld u opnieuw aan via AWS Marketplace.",
"goToMarketplace": "Ga naar AWS Marketplace",
"newUser": "Ben je nieuw bij OpenObserve?",
"newUserDescription": "Maak een nieuw account en nieuwe organisatie",
"createAccount": "Account aanmaken",
"existingUser": "Heb je al een account?",
"existingUserDescription": "Log in om een nieuwe organisatie te creëren met AWS-facturering",
"login": "Login",
"loggedInAs": "Ingelogd als",
"createNewOrg": "Creëer een nieuwe organisatie",
"createNewOrgDescription": "Creëer een nieuwe organisatie met AWS Marketplace-facturering",
"orgName": "Naam van de organisatie",
"createOrganization": "Creëer een organisatie",
"linkToExisting": "Link naar bestaande organisatie",
"linkToExistingDescription": "Koppel AWS-facturering aan een van uw gratis organisaties",
"selectOrganization": "Organisatie selecteren",
"linkAwsBilling": "AWS-facturering koppelen",
"settingUp": "Je abonnement instellen...",
"pleaseWait": "Een ogenblik geduld a.u.b. terwijl we uw account configureren.",
"waitingConfirmation": "Wachten op bevestiging van AWS",
"waitingDescription": "Je abonnement wordt geactiveerd. Dit duurt meestal minder dan een minuut.",
"customerId": "Klant-ID",
"subscriptionActivated": "Abonnement geactiveerd!",
"activatedDescription": "Je AWS Marketplace-abonnement is nu actief.",
"goToDashboard": "Ga naar het dashboard",
"paymentFailed": "Betaling is mislukt",
"paymentFailedDescription": "Er was een probleem met je AWS Marketplace-betaling. Controleer je AWS-account of neem contact op met AWS-ondersteuning.",
"contactSupport": "Contact opnemen met de klantenservice",
"tryAgain": "Probeer het opnieuw",
"activationTimeout": "Time-out voor activering. Neem contact op met de klantenservice als het probleem zich blijft voordoen."
}
}
+35
View File
@@ -2684,5 +2684,40 @@
"clearChatTitle": "Limpar bate-papo",
"clearChatMessage": "Tem certeza de que deseja encerrar essa conversa?",
"copiedToClipboard": "Copiado para a prancheta"
},
"awsMarketplace": {
"title": "Registro no AWS Marketplace",
"subtitle": "Conclua a configuração da sua assinatura do OpenObserve",
"invalidLink": "Link de registro inválido",
"invalidLinkDescription": "Este link de registro é inválido ou expirou. Inscreva-se novamente no AWS Marketplace.",
"goToMarketplace": "Acesse o AWS Marketplace",
"newUser": "Novo no OpenObserve?",
"newUserDescription": "Crie uma nova conta e organização",
"createAccount": "Criar conta",
"existingUser": "Já tem uma conta?",
"existingUserDescription": "Faça login para criar uma nova organização com o faturamento da AWS",
"login": "Login",
"loggedInAs": "Logado como",
"createNewOrg": "Criar nova organização",
"createNewOrgDescription": "Crie uma nova organização com o faturamento do AWS Marketplace",
"orgName": "Nome da organização",
"createOrganization": "Criar organização",
"linkToExisting": "Link para a organização existente",
"linkToExistingDescription": "Vincule o faturamento da AWS a uma de suas organizações de nível gratuito",
"selectOrganization": "Selecione a organização",
"linkAwsBilling": "Vincular o faturamento da AWS",
"settingUp": "Configurando sua assinatura...",
"pleaseWait": "Aguarde enquanto configuramos sua conta.",
"waitingConfirmation": "Aguardando a confirmação da AWS",
"waitingDescription": "Sua assinatura está sendo ativada. Isso geralmente leva menos de um minuto.",
"customerId": "ID do cliente",
"subscriptionActivated": "Assinatura ativada!",
"activatedDescription": "Sua assinatura do AWS Marketplace agora está ativa.",
"goToDashboard": "Vá para o painel",
"paymentFailed": "Falha no pagamento",
"paymentFailedDescription": "Houve um problema com seu pagamento do AWS Marketplace. Verifique sua conta da AWS ou entre em contato com o suporte da AWS.",
"contactSupport": "Contate o suporte",
"tryAgain": "Tente novamente",
"activationTimeout": "Tempo limite de ativação. Entre em contato com o suporte se o problema persistir."
}
}
+35
View File
@@ -2684,5 +2684,40 @@
"clearChatTitle": "Sohbeti Temizle",
"clearChatMessage": "Bu konuşmayı temizlemek istediğinden emin misin?",
"copiedToClipboard": "Panoya kopyalandı"
},
"awsMarketplace": {
"title": "AWS Marketplace Kaydı",
"subtitle": "OpenObserve abonelik kurulumunuzu tamamlayın",
"invalidLink": "Geçersiz Kayıt Bağlantısı",
"invalidLinkDescription": "Bu kayıt bağlantısı geçersiz veya süresi doldu. Lütfen AWS Marketplace'ten tekrar abone olun.",
"goToMarketplace": "AWS Marketplace'e gidin",
"newUser": "OpenObservere'de yeni misiniz?",
"newUserDescription": "Yeni bir hesap ve kuruluş oluşturun",
"createAccount": "Hesap Oluştur",
"existingUser": "Zaten bir hesabınız var mı?",
"existingUserDescription": "AWS faturalandırması ile yeni bir kuruluş oluşturmak için oturum açın",
"login": "Oturum aç",
"loggedInAs": "Olarak oturum açtı",
"createNewOrg": "Yeni Organizasyon Oluştur",
"createNewOrgDescription": "AWS Marketplace faturalandırması ile yeni bir kuruluş oluşturun",
"orgName": "Kuruluş Adı",
"createOrganization": "Organizasyon Oluştur",
"linkToExisting": "Mevcut Kuruluşa Bağlantı",
"linkToExistingDescription": "AWS faturalandırmasını ücretsiz katmanlı kuruluşlarınızdan birine bağlayın",
"selectOrganization": "Organizasyon Seçin",
"linkAwsBilling": "AWS Faturalandırmayı Bağlayın",
"settingUp": "Aboneliğinizi ayarlama...",
"pleaseWait": "Hesabınızı yapılandırırken lütfen bekleyin.",
"waitingConfirmation": "AWS Onayı Bekliyor",
"waitingDescription": "Aboneliğiniz etkinleştiriliyor. Bu genellikle bir dakikadan az sürer.",
"customerId": "Müşteri Kimliği",
"subscriptionActivated": "Abonelik Etkinleştirildi!",
"activatedDescription": "AWS Marketplace aboneliğiniz artık aktif.",
"goToDashboard": "Kontrol Paneline Git",
"paymentFailed": "Ödeme Başarısız",
"paymentFailedDescription": "AWS Marketplace ödemenizle ilgili bir sorun oluştu. Lütfen AWS hesabınızı kontrol edin veya AWS desteğiyle iletişime geçin.",
"contactSupport": "Destek ile iletişime geçin",
"tryAgain": "Tekrar dene",
"activationTimeout": "Aktivasyon zaman aşımı. Sorun devam ederse lütfen destek ekibiyle iletişime geçin."
}
}
+35
View File
@@ -2680,5 +2680,40 @@
"clearChatTitle": "清除聊天",
"clearChatMessage": "你确定要清除这个对话吗?",
"copiedToClipboard": "已复制到剪贴板"
},
"awsMarketplace": {
"title": "AWS 市场注册",
"subtitle": "完成 OpenObserve 订阅设置",
"invalidLink": "注册链接无效",
"invalidLinkDescription": "此注册链接无效或已过期。请再次从 AWS Marketplace 订阅。",
"goToMarketplace": "前往 AWS 市场",
"newUser": "OpenObserve 的新手",
"newUserDescription": "创建新账户和组织",
"createAccount": "创建账户",
"existingUser": "已经有账号了吗?",
"existingUserDescription": "登录使用 AWS 账单创建新组织",
"login": "登录",
"loggedInAs": "以身份登录",
"createNewOrg": "创建新组织",
"createNewOrgDescription": "使用 AWS Marketplace 账单创建新组织",
"orgName": "组织名称",
"createOrganization": "创建组织",
"linkToExisting": "链接到现有组织",
"linkToExistingDescription": "将 AWS 账单关联到您的一个免费套餐组织",
"selectOrganization": "选择组织",
"linkAwsBilling": "关联 AWS 账单",
"settingUp": "正在设置您的订阅...",
"pleaseWait": "我们正在配置您的账户,请稍候。",
"waitingConfirmation": "等待 AWS 确认",
"waitingDescription": "您的订阅已激活。这通常需要不到一分钟。",
"customerId": "客户 ID",
"subscriptionActivated": "订阅已激活!",
"activatedDescription": "您的 AWS 商城订阅现已激活。",
"goToDashboard": "前往控制面板",
"paymentFailed": "付款失败",
"paymentFailedDescription": "您的 AWS 商城付款出现问题。请检查您的 AWS 账户或联系 AWS 支持。",
"contactSupport": "联系支持部门",
"tryAgain": "再试一次",
"activationTimeout": "激活超时。如果问题仍然存在,请联系支持部门。"
}
}
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import http from "./http";
export interface LinkSubscriptionRequest {
token: string;
}
export interface LinkSubscriptionResponse {
success: boolean;
customer_identifier: string;
message?: string;
}
export interface ActivationStatusResponse {
status: "pending_activation" | "active" | "payment_failed" | "cancelled";
customer_identifier: string;
org_id: string;
message?: string;
activated_at?: string;
}
const awsMarketplace = {
/**
* Link an AWS Marketplace subscription to an organization
* Called after user is redirected from AWS Marketplace with a token
*/
linkSubscription: (
org_identifier: string,
token: string
): Promise<{ data: LinkSubscriptionResponse }> => {
return http().post(`/api/${org_identifier}/aws-marketplace/link-subscription`, {
token,
});
},
/**
* Check the activation status of an AWS Marketplace subscription
* Poll this endpoint to wait for subscribe-success SNS notification
*/
getActivationStatus: (
org_identifier: string,
customer_id: string
): Promise<{ data: ActivationStatusResponse }> => {
return http().get(
`/api/${org_identifier}/aws-marketplace/activation-status?customer_id=${customer_id}`
);
},
};
export default awsMarketplace;
+51
View File
@@ -0,0 +1,51 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import http from "./http";
export interface LinkSubscriptionRequest {
token: string;
}
export interface LinkSubscriptionResponse {
success: boolean;
customer_identifier: string;
message?: string;
}
export interface ActivationStatusResponse {
status: "active" | "payment_failed" | "cancelled";
customer_identifier: string;
org_id: string;
message?: string;
activated_at?: string;
}
const azureMarketplace = {
/**
* Link an Azure Marketplace subscription to an organization
* Called after user is redirected from Azure Marketplace with a token
*/
linkSubscription: (
org_identifier: string,
token: string
): Promise<{ data: LinkSubscriptionResponse }> => {
return http().post(`/api/${org_identifier}/azure-marketplace/link-subscription`, {
token,
});
}
};
export default azureMarketplace;
+445
View File
@@ -0,0 +1,445 @@
<!-- Copyright 2025 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="aws-marketplace-setup">
<div class="flex relative-position tw-px-3 tw-pt-2">
<img
class="appLogo"
loading="lazy"
:src="
store?.state?.theme === 'dark'
? getImageURL('images/common/openobserve_latest_dark_2.svg')
: getImageURL('images/common/openobserve_latest_light_2.svg')
"
/>
</div>
<div class="setup-container q-pa-xl">
<!-- No Token Error -->
<div v-if="state === 'no_token'" class="text-center">
<q-icon name="warning" size="80px" color="warning" />
<h5 class="q-mt-md">No Marketplace Token Found</h5>
<p class="text-grey-7">
Please start the registration process from AWS Marketplace.
</p>
<q-btn
color="primary"
label="Go to Dashboard"
@click="goToDashboard"
class="q-mt-lg"
/>
</div>
<!-- Error State -->
<div v-else-if="state === 'error'" class="text-center">
<q-icon name="error" size="80px" color="negative" />
<h5 class="q-mt-md">{{ errorMessage }}</h5>
<q-btn
color="primary"
label="Try Again"
@click="resetAndRetry"
class="q-mt-lg"
/>
</div>
<!-- Org Selection/Creation -->
<div v-else-if="state === 'select_org'" class="text-center">
<q-icon name="cloud" size="60px" color="primary" />
<h4 class="q-mt-md">Complete AWS Marketplace Setup</h4>
<p class="text-grey-7 q-mb-lg">
Link your AWS Marketplace subscription to an organization
</p>
<div class="options-container">
<!-- Create New Org -->
<q-card flat bordered class="option-card q-mb-md">
<q-card-section>
<div class="text-h6">Create New Organization</div>
<p class="text-grey-7">
Create a new organization with AWS Marketplace billing
</p>
<q-input
v-model="newOrgName"
label="Organization Name"
outlined
dense
class="q-mb-md"
:rules="[(val) => !!val || 'Organization name is required']"
/>
<q-btn
color="primary"
label="Create & Link"
@click="createNewOrgWithAws"
:loading="isProcessing"
:disable="!newOrgName"
class="full-width"
/>
</q-card-section>
</q-card>
<!-- Link to Existing Org (only show orgs without billing) -->
<q-card
v-if="eligibleOrganizations.length > 0"
flat
bordered
class="option-card"
>
<q-card-section>
<div class="text-h6">Link to Existing Organization</div>
<p class="text-grey-7">
Link AWS billing to an existing organization
</p>
<q-select
v-model="selectedOrg"
:options="eligibleOrganizations"
option-label="name"
option-value="identifier"
label="Select Organization"
outlined
dense
class="q-mb-md"
/>
<q-btn
color="primary"
label="Link AWS Billing"
@click="linkToExistingOrg"
:loading="isProcessing"
:disable="!selectedOrg"
class="full-width"
/>
</q-card-section>
</q-card>
</div>
</div>
<!-- Processing State -->
<div v-else-if="state === 'processing'" class="text-center">
<q-spinner-dots size="60px" color="primary" />
<h5 class="q-mt-md">Setting up your subscription...</h5>
<p class="text-grey-7">Please wait while we configure your account.</p>
</div>
<!-- Pending Activation State -->
<div v-else-if="state === 'pending_activation'" class="text-center">
<h5 class="q-mb-lg">Waiting for AWS Confirmation</h5>
<div class="flex justify-center">
<q-spinner-gears size="80px" color="primary" />
</div>
<p class="text-grey-7 q-mt-lg">
Please wait while we confirm activation with AWS and set up your account.
</p>
</div>
<!-- Success State -->
<div v-else-if="state === 'success'" class="text-center">
<q-icon name="check_circle" size="80px" color="positive" />
<h4 class="q-mt-md">Subscription Activated!</h4>
<p class="text-grey-7">
Your AWS Marketplace subscription is now active.
</p>
<q-btn
color="primary"
label="Go to Dashboard"
@click="goToDashboard"
class="q-mt-lg"
size="lg"
/>
</div>
<!-- Payment Failed State -->
<div v-else-if="state === 'payment_failed'" class="text-center">
<q-icon name="error" size="80px" color="negative" />
<h5 class="q-mt-md">Payment Failed</h5>
<p class="text-grey-7">
There was an issue with your AWS Marketplace payment. Please check
your AWS account or contact AWS support.
</p>
<q-btn
color="primary"
label="Contact Support"
href="mailto:support@openobserve.ai"
class="q-mt-lg"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import { useQuasar } from "quasar";
import { getImageURL, useLocalOrganization } from "@/utils/zincutils";
import awsMarketplace from "@/services/awsMarketplace";
import organizationsService from "@/services/organizations";
type SetupState =
| "select_org"
| "no_token"
| "processing"
| "pending_activation"
| "success"
| "payment_failed"
| "error";
export default defineComponent({
name: "AwsMarketplaceSetup",
setup() {
const store = useStore();
const router = useRouter();
const q = useQuasar();
const state = ref<SetupState>("select_org");
const errorMessage = ref("");
const isProcessing = ref(false);
const newOrgName = ref("");
const selectedOrg = ref<{ identifier: string; name: string } | null>(null);
const eligibleOrganizations = ref<{ identifier: string; name: string }[]>(
[]
);
const token = ref("");
const activatedOrgId = ref("");
let pollInterval: ReturnType<typeof setInterval> | null = null;
// Helper to get cookie value
const getCookie = (name: string): string | null => {
const match = document.cookie.match(new RegExp('(^|; )' + name + '=([^;]+)'));
return match ? decodeURIComponent(match[2]) : null;
};
// Helper to delete cookie
const deleteCookie = (name: string) => {
document.cookie = name + '=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
};
onMounted(async () => {
// Get token from cookie (set by backend at /api/aws-marketplace/register)
token.value = getCookie("aws_marketplace_token") || "";
if (!token.value) {
state.value = "no_token";
return;
}
// Fetch user's organizations
await fetchOrganizations();
});
onUnmounted(() => {
if (pollInterval) {
clearInterval(pollInterval);
}
});
const fetchOrganizations = async () => {
try {
const res = await organizationsService.list(0, 100000, "id", false, "");
// Show all orgs for now - backend will validate eligibility
eligibleOrganizations.value = res.data.data.map((org: any) => ({
identifier: org.identifier,
name: org.name,
}));
} catch (error) {
console.error("Failed to fetch organizations:", error);
}
};
const createNewOrgWithAws = async () => {
if (!newOrgName.value) {
q.notify({
type: "negative",
message: "Please enter an organization name",
});
return;
}
isProcessing.value = true;
state.value = "processing";
try {
// Create the organization
const orgRes = await organizationsService.create({
name: newOrgName.value,
});
const orgId = orgRes.data.identifier;
// Link AWS Marketplace subscription
await linkSubscription(orgId);
} catch (error: any) {
console.error("Failed to create organization:", error);
state.value = "error";
errorMessage.value =
error.response?.data?.message || "Failed to create organization";
isProcessing.value = false;
}
};
const linkToExistingOrg = async () => {
if (!selectedOrg.value) {
q.notify({
type: "negative",
message: "Please select an organization",
});
return;
}
isProcessing.value = true;
state.value = "processing";
await linkSubscription(selectedOrg.value.identifier);
};
const linkSubscription = async (orgId: string) => {
try {
const response = await awsMarketplace.linkSubscription(
orgId,
token.value
);
if (response.data.success) {
// Clear the token cookie immediately after successful link
// The token has been used (ResolveCustomer called) and can't be reused
deleteCookie("aws_marketplace_token");
token.value = "";
activatedOrgId.value = orgId;
state.value = "pending_activation";
// Start polling for activation status
startPolling(orgId, response.data.customer_identifier);
} else {
throw new Error(response.data.message || "Link subscription failed");
}
} catch (error: any) {
console.error("Failed to link subscription:", error);
state.value = "error";
errorMessage.value =
error.response?.data?.message || "Failed to link AWS subscription";
isProcessing.value = false;
}
};
const startPolling = (orgId: string, customerIdentifier: string) => {
let attempts = 0;
const maxAttempts = 60; // 5 minutes at 5 second intervals
pollInterval = setInterval(async () => {
attempts++;
try {
const response = await awsMarketplace.getActivationStatus(
orgId,
customerIdentifier
);
const status = response.data.status;
if (status === "active") {
if (pollInterval) clearInterval(pollInterval);
state.value = "success";
isProcessing.value = false;
// Update selected org in store
const orgData = {
identifier: orgId,
label: newOrgName.value || selectedOrg.value?.name || orgId,
user_email: store.state.userInfo?.email,
};
useLocalOrganization(orgData);
store.dispatch("setSelectedOrganization", orgData);
} else if (status === "payment_failed") {
if (pollInterval) clearInterval(pollInterval);
state.value = "payment_failed";
isProcessing.value = false;
} else if (attempts >= maxAttempts) {
if (pollInterval) clearInterval(pollInterval);
state.value = "error";
errorMessage.value =
"Activation timeout. Please contact support if the issue persists.";
isProcessing.value = false;
}
} catch (error) {
console.error("Poll error:", error);
}
}, 5000);
};
const goToDashboard = () => {
router.push({
path: "/",
query: activatedOrgId.value
? { org_identifier: activatedOrgId.value }
: undefined,
});
};
const resetAndRetry = () => {
state.value = "select_org";
errorMessage.value = "";
isProcessing.value = false;
};
return {
store,
state,
errorMessage,
isProcessing,
newOrgName,
selectedOrg,
eligibleOrganizations,
getImageURL,
createNewOrgWithAws,
linkToExistingOrg,
goToDashboard,
resetAndRetry,
};
},
});
</script>
<style lang="scss" scoped>
.aws-marketplace-setup {
min-height: 100vh;
background: var(--q-background);
}
.appLogo {
height: 40px;
}
.setup-container {
max-width: 500px;
margin: 0 auto;
padding-top: 60px;
}
.options-container {
max-width: 400px;
margin: 0 auto;
}
.option-card {
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
</style>
+371
View File
@@ -0,0 +1,371 @@
<!-- Copyright 2025 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="azure-marketplace-setup">
<div class="flex relative-position tw-px-3 tw-pt-2">
<img
class="appLogo"
loading="lazy"
:src="
store?.state?.theme === 'dark'
? getImageURL('images/common/openobserve_latest_dark_2.svg')
: getImageURL('images/common/openobserve_latest_light_2.svg')
"
/>
</div>
<div class="setup-container q-pa-xl">
<!-- No Token Error -->
<div v-if="state === 'no_token'" class="text-center">
<q-icon name="warning" size="80px" color="warning" />
<h5 class="q-mt-md">No Marketplace Token Found</h5>
<p class="text-grey-7">
Please start the registration process from Azure Marketplace.
</p>
<q-btn
color="primary"
label="Go to Dashboard"
@click="goToDashboard"
class="q-mt-lg"
/>
</div>
<!-- Error State -->
<div v-else-if="state === 'error'" class="text-center">
<q-icon name="error" size="80px" color="negative" />
<h5 class="q-mt-md">{{ errorMessage }}</h5>
<q-btn
color="primary"
label="Try Again"
@click="resetAndRetry"
class="q-mt-lg"
/>
</div>
<!-- Org Selection/Creation -->
<div v-else-if="state === 'select_org'" class="text-center">
<q-icon name="cloud" size="60px" color="primary" />
<h4 class="q-mt-md">Complete Azure Marketplace Setup</h4>
<p class="text-grey-7 q-mb-lg">
Link your Azure Marketplace subscription to an organization
</p>
<div class="options-container">
<!-- Create New Org -->
<q-card flat bordered class="option-card q-mb-md">
<q-card-section>
<div class="text-h6">Create New Organization</div>
<p class="text-grey-7">
Create a new organization with Azure Marketplace billing
</p>
<q-input
v-model="newOrgName"
label="Organization Name"
outlined
dense
class="q-mb-md"
:rules="[(val) => !!val || 'Organization name is required']"
/>
<q-btn
color="primary"
label="Create & Link"
@click="createNewOrgForAzure"
:loading="isProcessing"
:disable="!newOrgName"
class="full-width"
/>
</q-card-section>
</q-card>
<!-- Link to Existing Org (only show orgs without billing) -->
<q-card
v-if="eligibleOrganizations.length > 0"
flat
bordered
class="option-card"
>
<q-card-section>
<div class="text-h6">Link to Existing Organization</div>
<p class="text-grey-7">
Link Azure billing to an existing organization
</p>
<q-select
v-model="selectedOrg"
:options="eligibleOrganizations"
option-label="name"
option-value="identifier"
label="Select Organization"
outlined
dense
class="q-mb-md"
/>
<q-btn
color="primary"
label="Link Azure Billing"
@click="linkToExistingOrg"
:loading="isProcessing"
:disable="!selectedOrg"
class="full-width"
/>
</q-card-section>
</q-card>
</div>
</div>
<!-- Processing State -->
<div v-else-if="state === 'processing'" class="text-center">
<q-spinner-dots size="60px" color="primary" />
<h5 class="q-mt-md">Setting up your subscription...</h5>
<p class="text-grey-7">Please wait while we configure your account.</p>
</div>
<!-- Success State -->
<div v-else-if="state === 'success'" class="text-center">
<q-icon name="check_circle" size="80px" color="positive" />
<h4 class="q-mt-md">Subscription Activated!</h4>
<p class="text-grey-7">
Your Azure Marketplace subscription is now active.
</p>
<q-btn
color="primary"
label="Go to Dashboard"
@click="goToDashboard"
class="q-mt-lg"
size="lg"
/>
</div>
<!-- Payment Failed State -->
<div v-else-if="state === 'payment_failed'" class="text-center">
<q-icon name="error" size="80px" color="negative" />
<h5 class="q-mt-md">Payment Failed</h5>
<p class="text-grey-7">
There was an issue with activating Azure subscription. Please check
your Azure account or contact support.
</p>
<q-btn
color="primary"
label="Contact Support"
href="mailto:support@openobserve.ai"
class="q-mt-lg"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import { useQuasar } from "quasar";
import { getImageURL, useLocalOrganization } from "@/utils/zincutils";
import azureMarketplace from "@/services/azureMarketplace";
import organizationsService from "@/services/organizations";
type SetupState =
| "select_org"
| "no_token"
| "processing"
| "success"
| "payment_failed"
| "error";
export default defineComponent({
name: "AzureMarketplaceSetup",
setup() {
const store = useStore();
const router = useRouter();
const q = useQuasar();
const state = ref<SetupState>("select_org");
const errorMessage = ref("");
const isProcessing = ref(false);
const newOrgName = ref("");
const selectedOrg = ref<{ identifier: string; name: string } | null>(null);
const eligibleOrganizations = ref<{ identifier: string; name: string }[]>(
[]
);
const token = ref("");
const activatedOrgId = ref("");
onMounted(async () => {
// Get token from sessionStorage (saved by /marketplace/azure/register route)
token.value = sessionStorage.getItem("azure_marketplace_token") || "";
if (!token.value) {
state.value = "no_token";
return;
}
// Fetch user's organizations
await fetchOrganizations();
});
const fetchOrganizations = async () => {
try {
const res = await organizationsService.list(0, 100000, "id", false, "");
// Show all orgs for now - backend will validate eligibility
eligibleOrganizations.value = res.data.data.map((org: any) => ({
identifier: org.identifier,
name: org.name,
}));
} catch (error) {
console.error("Failed to fetch organizations:", error);
}
};
const createNewOrgForAzure = async () => {
if (!newOrgName.value) {
q.notify({
type: "negative",
message: "Please enter an organization name",
});
return;
}
isProcessing.value = true;
state.value = "processing";
try {
// Create the organization
const orgRes = await organizationsService.create({
name: newOrgName.value,
});
const orgId = orgRes.data.identifier;
// Link AWS Marketplace subscription
await linkSubscription(orgId);
} catch (error: any) {
console.error("Failed to create organization:", error);
state.value = "error";
errorMessage.value =
error.response?.data?.message || "Failed to create organization";
isProcessing.value = false;
}
};
const linkToExistingOrg = async () => {
if (!selectedOrg.value) {
q.notify({
type: "negative",
message: "Please select an organization",
});
return;
}
isProcessing.value = true;
state.value = "processing";
await linkSubscription(selectedOrg.value.identifier);
};
const linkSubscription = async (orgId: string) => {
try {
await azureMarketplace.linkSubscription(
orgId,
token.value
);
// Clear the token from sessionStorage
sessionStorage.removeItem("azure_marketplace_token");
state.value = "success";
isProcessing.value = false;
// Update selected org in store
const orgData = {
identifier: orgId,
label: newOrgName.value || selectedOrg.value?.name || orgId,
user_email: store.state.userInfo?.email,
};
useLocalOrganization(orgData);
store.dispatch("setSelectedOrganization", orgData);
} catch (error: any) {
console.error("Failed to link subscription:", error);
state.value = "error";
errorMessage.value =
error.response?.data?.message || "Failed to link Azure subscription";
isProcessing.value = false;
}
};
const goToDashboard = () => {
sessionStorage.removeItem("azure_marketplace_token");
router.push({
path: "/",
query: activatedOrgId.value
? { org_identifier: activatedOrgId.value }
: undefined,
});
};
const resetAndRetry = () => {
state.value = "select_org";
errorMessage.value = "";
isProcessing.value = false;
};
return {
store,
state,
errorMessage,
isProcessing,
newOrgName,
selectedOrg,
eligibleOrganizations,
getImageURL,
createNewOrgForAzure,
linkToExistingOrg,
goToDashboard,
resetAndRetry,
};
},
});
</script>
<style lang="scss" scoped>
.azure-marketplace-setup {
min-height: 100vh;
background: var(--q-background);
}
.appLogo {
height: 40px;
}
.setup-container {
max-width: 500px;
margin: 0 auto;
padding-top: 60px;
}
.options-container {
max-width: 400px;
margin: 0 auto;
}
.option-card {
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
</style>
+20 -3
View File
@@ -455,21 +455,33 @@ describe("Login.vue", () => {
});
it("should redirect to sessionStorage URI when available", async () => {
mockSessionStorage.getItem.mockReturnValue("/dashboard");
// Mock azure_marketplace_token as null so it checks redirectURI
mockSessionStorage.getItem.mockImplementation((key: string) => {
if (key === "azure_marketplace_token") return null;
if (key === "redirectURI") return "/dashboard";
return null;
});
const pushSpy = vi.spyOn(router, "push");
wrapper.vm.redirectUser();
expect(mockSessionStorage.getItem).toHaveBeenCalledWith("azure_marketplace_token");
expect(mockSessionStorage.getItem).toHaveBeenCalledWith("redirectURI");
expect(mockSessionStorage.removeItem).toHaveBeenCalledWith("redirectURI");
expect(pushSpy).toHaveBeenCalledWith({ path: "/dashboard" });
});
it("should redirect to external URL when redirectURI contains http", async () => {
mockSessionStorage.getItem.mockReturnValue("https://external.com");
// Mock azure_marketplace_token as null so it checks redirectURI
mockSessionStorage.getItem.mockImplementation((key: string) => {
if (key === "azure_marketplace_token") return null;
if (key === "redirectURI") return "https://external.com";
return null;
});
wrapper.vm.redirectUser();
expect(mockSessionStorage.getItem).toHaveBeenCalledWith("azure_marketplace_token");
expect(mockSessionStorage.getItem).toHaveBeenCalledWith("redirectURI");
expect(mockSessionStorage.removeItem).toHaveBeenCalledWith("redirectURI");
expect(window.location.href).toBe("https://external.com");
@@ -494,7 +506,12 @@ describe("Login.vue", () => {
});
it("should always remove redirectURI from sessionStorage", async () => {
mockSessionStorage.getItem.mockReturnValue("/some-path");
// Mock azure_marketplace_token as null so it checks redirectURI
mockSessionStorage.getItem.mockImplementation((key: string) => {
if (key === "azure_marketplace_token") return null;
if (key === "redirectURI") return "/some-path";
return null;
});
wrapper.vm.redirectUser();
+21
View File
@@ -209,11 +209,32 @@ export default defineComponent({
}, 1000);
};
/**
* Helper to get cookie value by name
*/
const getCookie = (name: string): string | null => {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? decodeURIComponent(match[2]) : null;
};
/**
* Redirect user to the page where user was redirected from
* @param redirectURI
*/
const redirectUser = () => {
// Check for AWS Marketplace token (set by backend as cookie) - redirect to setup if present
const awsMarketplaceToken = getCookie("aws_marketplace_token");
if (awsMarketplaceToken) {
router.push({ path: "/marketplace/aws/setup" });
return;
}
// Check for Azure Marketplace token - redirect to setup if present
const azureMarketplaceToken = window.sessionStorage.getItem("azure_marketplace_token");
if (azureMarketplaceToken) {
router.push({ path: "/marketplace/azure/register" });
return;
}
const redirectURI = window.sessionStorage.getItem("redirectURI");
window.sessionStorage.removeItem("redirectURI");
if (redirectURI != null && redirectURI != "") {