mirror of https://github.com/Squidex/squidex.git
49 changed files with 852 additions and 99 deletions
@ -0,0 +1,18 @@ |
|||
// ==========================================================================
|
|||
// AppPlanChanged.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Events.Apps |
|||
{ |
|||
[TypeName("AppPlanChanged")] |
|||
public sealed class AppPlanChanged : AppEvent |
|||
{ |
|||
public string PlanId { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,26 @@ |
|||
// ==========================================================================
|
|||
// IAppPlanBillingManager.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Squidex.Read.Apps.Services |
|||
{ |
|||
public interface IAppPlanBillingManager |
|||
{ |
|||
bool HasPortal { get; } |
|||
|
|||
string FreePlanId { get; } |
|||
|
|||
Task ChangePlanAsync(string userId, Guid appId, string appName, string planId); |
|||
|
|||
Task<bool> HasPaymentOptionsAsync(string userId); |
|||
|
|||
Task<string> GetPortalLinkAsync(string userId); |
|||
} |
|||
} |
|||
@ -0,0 +1,42 @@ |
|||
// ==========================================================================
|
|||
// NoopAppPlanBillingManager.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Squidex.Infrastructure.Tasks; |
|||
|
|||
namespace Squidex.Read.Apps.Services.Implementations |
|||
{ |
|||
public sealed class NoopAppPlanBillingManager : IAppPlanBillingManager |
|||
{ |
|||
public bool HasPortal |
|||
{ |
|||
get { return false; } |
|||
} |
|||
|
|||
public string FreePlanId |
|||
{ |
|||
get { return "free"; } |
|||
} |
|||
|
|||
public Task ChangePlanAsync(string userId, Guid appId, string appName, string planId) |
|||
{ |
|||
return TaskHelper.Done; |
|||
} |
|||
|
|||
public Task<bool> HasPaymentOptionsAsync(string userId) |
|||
{ |
|||
return TaskHelper.True; |
|||
} |
|||
|
|||
public Task<string> GetPortalLinkAsync(string userId) |
|||
{ |
|||
return Task.FromResult(string.Empty); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
// ==========================================================================
|
|||
// ChangePlanCommand.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Write.Apps.Commands |
|||
{ |
|||
public sealed class ChangePlan : AppAggregateCommand |
|||
{ |
|||
public string PlanId { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,95 @@ |
|||
// ==========================================================================
|
|||
// AppPlansController.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Mvc; |
|||
using Microsoft.Extensions.Primitives; |
|||
using NSwag.Annotations; |
|||
using Squidex.Controllers.Api.Plans.Models; |
|||
using Squidex.Infrastructure.CQRS.Commands; |
|||
using Squidex.Infrastructure.Reflection; |
|||
using Squidex.Infrastructure.Security; |
|||
using Squidex.Pipeline; |
|||
using Squidex.Read.Apps.Services; |
|||
using Squidex.Write.Apps.Commands; |
|||
|
|||
namespace Squidex.Controllers.Api.Plans |
|||
{ |
|||
/// <summary>
|
|||
/// Manages and configures plans.
|
|||
/// </summary>
|
|||
[ApiExceptionFilter] |
|||
[AppApi] |
|||
[SwaggerTag("Plans")] |
|||
public class AppPlansController : ControllerBase |
|||
{ |
|||
private readonly IAppPlansProvider appPlansProvider; |
|||
private readonly IAppPlanBillingManager appPlansBillingManager; |
|||
|
|||
public AppPlansController(ICommandBus commandBus, IAppPlansProvider appPlansProvider, IAppPlanBillingManager appPlansBillingManager) |
|||
: base(commandBus) |
|||
{ |
|||
this.appPlansProvider = appPlansProvider; |
|||
this.appPlansBillingManager = appPlansBillingManager; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Get app plan information.
|
|||
/// </summary>
|
|||
/// <param name="app">The name of the app.</param>
|
|||
/// <returns>
|
|||
/// 200 => App plan information returned.
|
|||
/// 404 => App not found.
|
|||
/// </returns>
|
|||
[MustBeAppOwner] |
|||
[HttpGet] |
|||
[Route("apps/{app}/plans/")] |
|||
[ProducesResponseType(typeof(AppPlansDto), 200)] |
|||
[ApiCosts(0.5)] |
|||
public async Task<IActionResult> GetPlans(string app) |
|||
{ |
|||
var userId = User.FindFirst(OpenIdClaims.Subject).Value; |
|||
|
|||
var response = new AppPlansDto |
|||
{ |
|||
Plans = appPlansProvider.GetAvailablePlans().Select(x => SimpleMapper.Map(x, new PlanDto())).ToList(), |
|||
PlanOwner = App.PlanOwner, |
|||
HasPortal = appPlansBillingManager.HasPortal, |
|||
HasConfigured = await appPlansBillingManager.HasPaymentOptionsAsync(userId), |
|||
CurrentPlanId = !string.IsNullOrWhiteSpace(App.PlanId) ? App.PlanId : appPlansBillingManager.FreePlanId |
|||
}; |
|||
|
|||
Response.Headers["ETag"] = new StringValues(App.Version.ToString()); |
|||
|
|||
return Ok(response); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Change the app plan.
|
|||
/// </summary>
|
|||
/// <param name="app">The name of the app.</param>
|
|||
/// <param name="request">Plan object that needs to be changed.</param>
|
|||
/// <returns>
|
|||
/// 204 => Plan changed.
|
|||
/// 400 => Plan not owned by user.
|
|||
/// 404 => App not found.
|
|||
/// </returns>
|
|||
[MustBeAppOwner] |
|||
[HttpPut] |
|||
[Route("apps/{app}/plan/")] |
|||
[ProducesResponseType(typeof(ErrorDto), 400)] |
|||
[ApiCosts(0.5)] |
|||
public async Task<IActionResult> ChangePlanAsync(string app, [FromBody] ChangePlanDto request) |
|||
{ |
|||
await CommandBus.PublishAsync(SimpleMapper.Map(request, new ChangePlan())); |
|||
|
|||
return NoContent(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
// ==========================================================================
|
|||
// AppPlansDto.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Collections.Generic; |
|||
|
|||
namespace Squidex.Controllers.Api.Plans.Models |
|||
{ |
|||
public class AppPlansDto |
|||
{ |
|||
/// <summary>
|
|||
/// The available plans.
|
|||
/// </summary>
|
|||
public List<PlanDto> Plans { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The current plan id.
|
|||
/// </summary>
|
|||
public string CurrentPlanId { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The plan owner.
|
|||
/// </summary>
|
|||
public string PlanOwner { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// Indicates if there is a billing portal.
|
|||
/// </summary>
|
|||
public bool HasPortal { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// Indicates if the user has payment options entered so that the plan can be changed.
|
|||
/// </summary>
|
|||
public bool HasConfigured { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// ChangePlanDto.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System.ComponentModel.DataAnnotations; |
|||
|
|||
namespace Squidex.Controllers.Api.Plans.Models |
|||
{ |
|||
public class ChangePlanDto |
|||
{ |
|||
/// <summary>
|
|||
/// The new plan id.
|
|||
/// </summary>
|
|||
[Required] |
|||
public string PlanId { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,43 @@ |
|||
// ==========================================================================
|
|||
// PlanDto.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Controllers.Api.Plans.Models |
|||
{ |
|||
public class PlanDto |
|||
{ |
|||
/// <summary>
|
|||
/// The id of the plan.
|
|||
/// </summary>
|
|||
public string Id { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The name of the plan.
|
|||
/// </summary>
|
|||
public string Name { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The monthly costs of the plan.
|
|||
/// </summary>
|
|||
public string Costs { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The maximum number of API calls.
|
|||
/// </summary>
|
|||
public long MaxApiCalls { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The maximum allowed asset size.
|
|||
/// </summary>
|
|||
public long MaxAssetSize { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The maximum number of contributors.
|
|||
/// </summary>
|
|||
public int MaxContributors { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
// ==========================================================================
|
|||
// PortalController.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Authorization; |
|||
using Microsoft.AspNetCore.Mvc; |
|||
using NSwag.Annotations; |
|||
using Squidex.Infrastructure.Security; |
|||
using Squidex.Read.Apps.Services; |
|||
|
|||
namespace Squidex.Controllers.UI.Profile |
|||
{ |
|||
[Authorize] |
|||
[SwaggerIgnore] |
|||
public class PortalController : Controller |
|||
{ |
|||
private readonly IAppPlanBillingManager appPlansBillingManager; |
|||
|
|||
public PortalController(IAppPlanBillingManager appPlansBillingManager) |
|||
{ |
|||
this.appPlansBillingManager = appPlansBillingManager; |
|||
} |
|||
|
|||
[HttpGet] |
|||
[Route("/account/portal")] |
|||
public async Task<IActionResult> Portal() |
|||
{ |
|||
var userId = User.FindFirst(OpenIdClaims.Subject).Value; |
|||
|
|||
var redirectUrl = await appPlansBillingManager.GetPortalLinkAsync(userId); |
|||
|
|||
return Redirect(redirectUrl); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,71 @@ |
|||
<sqx-title message="{app} | Plans | Settings" parameter1="app" value1="{{appName() | async}}"></sqx-title> |
|||
|
|||
<sqx-panel panelWidth="60rem"> |
|||
<div class="panel-header"> |
|||
<div class="panel-title-row"> |
|||
<div class="float-right"> |
|||
<button class="btn btn-link btn-decent" (click)="load(true)" title="Refresh Plans (CTRL + SHIFT + R)"> |
|||
<i class="icon-reset"></i> Refresh |
|||
</button> |
|||
|
|||
<sqx-shortcut keys="ctrl+shift+r" (trigger)="load(true)"></sqx-shortcut> |
|||
</div> |
|||
|
|||
<h3 class="panel-title">Update Plan</h3> |
|||
</div> |
|||
|
|||
<a class="panel-close" sqxParentLink> |
|||
<i class="icon-close"></i> |
|||
</a> |
|||
</div> |
|||
|
|||
<div class="panel-main"> |
|||
<div class="panel-content"> |
|||
<div *ngIf="plans"> |
|||
<div class="panel-alert panel-alert-danger" *ngIf="!plans.hasConfigured || !planOwned"> |
|||
<div *ngIf="!plans.hasConfigured"> |
|||
You have not configured your account yet. Go to <a target="_blank" href="{{portalUrl}}">Billing Portal</a> to add payment options. |
|||
</div> |
|||
<div *ngIf="!planOwned"> |
|||
You have not created the subscription. Therefore you cannot change the plan. |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="clearfix"> |
|||
<div class="card plan float-left" *ngFor="let plan of plans.plans"> |
|||
<div class="card-block plan-header text-center"> |
|||
<h4 class="card-title">{{plan.name}}</h4> |
|||
<h5 class="plan-price">{{plan.costs}}</h5> |
|||
|
|||
<small class="text-muted">Per Month</small> |
|||
</div> |
|||
<div class="card-block"> |
|||
<div class="plan-fact"> |
|||
{{formatCalls(plan.maxApiCalls)}} API Calls |
|||
</div> |
|||
<div class="plan-fact"> |
|||
{{formatSize(plan.maxAssetSize)}} Storage |
|||
</div> |
|||
<div class="plan-fact"> |
|||
{{plan.maxContributors}} Contributors |
|||
</div> |
|||
</div> |
|||
<div class="card-block"> |
|||
<button *ngIf="plan.id === plans.currentPlanId" class="btn btn-block btn-link btn-success plan-selected"> |
|||
✓ Selected |
|||
</button> |
|||
|
|||
<button *ngIf="plan.id !== plans.currentPlanId" class="btn btn-block btn-success" [disabled]="isDisabled || !plans.hasConfigured || !planOwned" (click)="changePlan(plan.id)"> |
|||
Change |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div *ngIf="plans.hasPortal" class="billing-portal-link"> |
|||
Go to <a target="_blank" href="{{portalUrl}}">Billing Portal</a> for payment history and subscription overview. |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</sqx-panel> |
|||
@ -0,0 +1,35 @@ |
|||
@import '_vars'; |
|||
@import '_mixins'; |
|||
|
|||
.panel-content { |
|||
padding-left: 1rem; |
|||
padding-right: 1rem; |
|||
} |
|||
|
|||
.plan { |
|||
& { |
|||
min-width: 13rem; |
|||
max-width: 20rem; |
|||
margin: .5rem; |
|||
} |
|||
|
|||
&-header { |
|||
border-bottom: 1px solid $color-border; |
|||
} |
|||
|
|||
&-price { |
|||
color: $color-theme-blue; |
|||
} |
|||
|
|||
&-selected { |
|||
pointer-events: none; |
|||
} |
|||
|
|||
&-fact { |
|||
line-height: 2rem; |
|||
} |
|||
} |
|||
|
|||
.billing-portal-link { |
|||
padding: 2rem .5rem 0; |
|||
} |
|||
@ -0,0 +1,108 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Sebastian Stehle. All rights reserved |
|||
*/ |
|||
|
|||
import { Component, OnInit } from '@angular/core'; |
|||
|
|||
import { |
|||
ApiUrlConfig, |
|||
AppComponentBase, |
|||
AppPlansDto, |
|||
AppsStoreService, |
|||
AuthService, |
|||
ChangePlanDto, |
|||
FileHelper, |
|||
NotificationService, |
|||
PlansService, |
|||
Version |
|||
} from 'shared'; |
|||
|
|||
@Component({ |
|||
selector: 'sqx-plans-page', |
|||
styleUrls: ['./plans-page.component.scss'], |
|||
templateUrl: './plans-page.component.html' |
|||
}) |
|||
export class PlansPageComponent extends AppComponentBase implements OnInit { |
|||
private version = new Version(); |
|||
|
|||
public portalUrl = this.apiUrl.buildUrl('/identity-server/account/portal'); |
|||
|
|||
public plans: AppPlansDto; |
|||
public planOwned = false; |
|||
|
|||
public isDisabled = false; |
|||
|
|||
constructor(apps: AppsStoreService, notifications: NotificationService, |
|||
private readonly authService: AuthService, |
|||
private readonly plansService: PlansService, |
|||
private readonly apiUrl: ApiUrlConfig |
|||
) { |
|||
super(notifications, apps); |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.load(); |
|||
} |
|||
|
|||
public load(showInfo = false) { |
|||
this.appNameOnce() |
|||
.switchMap(app => this.plansService.getPlans(app, this.version).retry(2)) |
|||
.subscribe(dto => { |
|||
this.plans = dto; |
|||
|
|||
this.planOwned = !dto.planOwner || (dto.planOwner === this.authService.user!.id); |
|||
|
|||
if (showInfo) { |
|||
this.notifyInfo('Plans reloaded.'); |
|||
} |
|||
}, error => { |
|||
this.notifyError(error); |
|||
}); |
|||
} |
|||
|
|||
public changePlan(planId: string) { |
|||
this.isDisabled = true; |
|||
|
|||
this.appNameOnce() |
|||
.switchMap(app => this.plansService.putPlan(app, new ChangePlanDto(planId), this.version)) |
|||
.subscribe(dto => { |
|||
this.plans = |
|||
new AppPlansDto(planId, |
|||
this.plans.planOwner, |
|||
this.plans.hasPortal, |
|||
this.plans.hasConfigured, |
|||
this.plans.plans); |
|||
this.isDisabled = false; |
|||
}, error => { |
|||
this.notifyError(error); |
|||
|
|||
this.isDisabled = false; |
|||
}); |
|||
} |
|||
|
|||
public formatSize(count: number): string { |
|||
return FileHelper.fileSize(count); |
|||
} |
|||
|
|||
public formatCalls(count: number): string { |
|||
if (count > 1000) { |
|||
count = count / 1000; |
|||
|
|||
if (count < 10) { |
|||
count = Math.round(count * 10) / 10; |
|||
} else { |
|||
count = Math.round(count); |
|||
} |
|||
|
|||
return count + 'k'; |
|||
} else if (count < 0) { |
|||
return undefined; |
|||
} else { |
|||
return count.toString(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,87 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Sebastian Stehle. All rights reserved |
|||
*/ |
|||
|
|||
import { Injectable } from '@angular/core'; |
|||
import { Observable } from 'rxjs'; |
|||
|
|||
import 'framework/angular/http-extensions'; |
|||
|
|||
import { ApiUrlConfig, Version } from 'framework'; |
|||
import { AuthService } from './auth.service'; |
|||
|
|||
export class AppPlansDto { |
|||
constructor( |
|||
public readonly currentPlanId: string, |
|||
public readonly planOwner: string, |
|||
public readonly hasPortal: boolean, |
|||
public readonly hasConfigured: boolean, |
|||
public readonly plans: PlanDto[] |
|||
) { |
|||
} |
|||
} |
|||
|
|||
export class PlanDto { |
|||
constructor( |
|||
public readonly id: string, |
|||
public readonly name: string, |
|||
public readonly costs: string, |
|||
public readonly maxApiCalls: number, |
|||
public readonly maxAssetSize: number, |
|||
public readonly maxContributors: number |
|||
) { |
|||
} |
|||
} |
|||
|
|||
|
|||
export class ChangePlanDto { |
|||
constructor( |
|||
public readonly planId: string |
|||
) { |
|||
} |
|||
} |
|||
|
|||
@Injectable() |
|||
export class PlansService { |
|||
constructor( |
|||
private readonly authService: AuthService, |
|||
private readonly apiUrl: ApiUrlConfig |
|||
) { |
|||
} |
|||
|
|||
public getPlans(appName: string, version?: Version): Observable<AppPlansDto> { |
|||
const url = this.apiUrl.buildUrl(`api/apps/${appName}/plans`); |
|||
|
|||
return this.authService.authGet(url, version) |
|||
.map(response => response.json()) |
|||
.map(response => { |
|||
const items: any[] = response.plans; |
|||
|
|||
return new AppPlansDto( |
|||
response.currentPlanId, |
|||
response.planOwner, |
|||
response.hasPortal, |
|||
response.hasConfigured, |
|||
items.map(item => { |
|||
return new PlanDto( |
|||
item.id, |
|||
item.name, |
|||
item.costs, |
|||
item.maxApiCalls, |
|||
item.maxAssetSize, |
|||
item.maxContributors); |
|||
})); |
|||
}) |
|||
.catchError('Failed to load plans. Please reload.'); |
|||
} |
|||
|
|||
public putPlan(appName: string, dto: ChangePlanDto, version?: Version): Observable<any> { |
|||
const url = this.apiUrl.buildUrl(`api/apps/${appName}/plan`); |
|||
|
|||
return this.authService.authPut(url, dto, version) |
|||
.catchError('Failed to change plan. Please reload.'); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue