fix(subscription): harden quantity updates and proxy trust behavior
Centralize min/max server limits in Stripe quantity updates and wire them into Livewire subscription actions with price preview/update handling. Also improve host/proxy middleware behavior by trusting loopback hosts when FQDN is set and auto-enabling secure session cookies for HTTPS requests behind proxies when session.secure is unset. Includes feature tests for loopback trust and secure cookie auto-detection.
This commit is contained in:
parent
76ae720c36
commit
d3b8d70f08
9 changed files with 423 additions and 99 deletions
|
|
@ -8,6 +8,10 @@
|
||||||
|
|
||||||
class UpdateSubscriptionQuantity
|
class UpdateSubscriptionQuantity
|
||||||
{
|
{
|
||||||
|
public const int MAX_SERVER_LIMIT = 100;
|
||||||
|
|
||||||
|
public const int MIN_SERVER_LIMIT = 2;
|
||||||
|
|
||||||
private StripeClient $stripe;
|
private StripeClient $stripe;
|
||||||
|
|
||||||
public function __construct(?StripeClient $stripe = null)
|
public function __construct(?StripeClient $stripe = null)
|
||||||
|
|
@ -60,6 +64,7 @@ public function fetchPricePreview(Team $team, int $quantity): array
|
||||||
$taxDescription = $taxRate->display_name.' ('.$taxRate->jurisdiction.') '.$taxRate->percentage.'%';
|
$taxDescription = $taxRate->display_name.' ('.$taxRate->jurisdiction.') '.$taxRate->percentage.'%';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Fallback tax percentage from invoice totals - use tax_rate details when available for accuracy
|
||||||
if ($taxPercentage === 0.0 && ($upcomingInvoice->tax ?? 0) > 0 && ($upcomingInvoice->subtotal ?? 0) > 0) {
|
if ($taxPercentage === 0.0 && ($upcomingInvoice->tax ?? 0) > 0 && ($upcomingInvoice->subtotal ?? 0) > 0) {
|
||||||
$taxPercentage = round(($upcomingInvoice->tax / $upcomingInvoice->subtotal) * 100, 2);
|
$taxPercentage = round(($upcomingInvoice->tax / $upcomingInvoice->subtotal) * 100, 2);
|
||||||
}
|
}
|
||||||
|
|
@ -110,8 +115,8 @@ public function fetchPricePreview(Team $team, int $quantity): array
|
||||||
*/
|
*/
|
||||||
public function execute(Team $team, int $quantity): array
|
public function execute(Team $team, int $quantity): array
|
||||||
{
|
{
|
||||||
if ($quantity < 2) {
|
if ($quantity < self::MIN_SERVER_LIMIT) {
|
||||||
return ['success' => false, 'error' => 'Minimum server limit is 2.'];
|
return ['success' => false, 'error' => 'Minimum server limit is '.self::MIN_SERVER_LIMIT.'.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$subscription = $team->subscription;
|
$subscription = $team->subscription;
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,13 @@ public function hosts(): array
|
||||||
// Trust all subdomains of APP_URL as fallback
|
// Trust all subdomains of APP_URL as fallback
|
||||||
$trustedHosts[] = $this->allSubdomainsOfApplicationUrl();
|
$trustedHosts[] = $this->allSubdomainsOfApplicationUrl();
|
||||||
|
|
||||||
|
// Always trust loopback addresses so local access works even when FQDN is configured
|
||||||
|
foreach (['localhost', '127.0.0.1', '[::1]'] as $localHost) {
|
||||||
|
if (! in_array($localHost, $trustedHosts, true)) {
|
||||||
|
$trustedHosts[] = $localHost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return array_filter($trustedHosts);
|
return array_filter($trustedHosts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,4 +25,26 @@ class TrustProxies extends Middleware
|
||||||
Request::HEADER_X_FORWARDED_PORT |
|
Request::HEADER_X_FORWARDED_PORT |
|
||||||
Request::HEADER_X_FORWARDED_PROTO |
|
Request::HEADER_X_FORWARDED_PROTO |
|
||||||
Request::HEADER_X_FORWARDED_AWS_ELB;
|
Request::HEADER_X_FORWARDED_AWS_ELB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the request.
|
||||||
|
*
|
||||||
|
* Wraps $next so that after proxy headers are resolved (X-Forwarded-Proto processed),
|
||||||
|
* the Secure cookie flag is auto-enabled when the request is over HTTPS.
|
||||||
|
* This ensures session cookies are correctly marked Secure when behind an HTTPS
|
||||||
|
* reverse proxy (Cloudflare Tunnel, nginx, etc.) even when SESSION_SECURE_COOKIE
|
||||||
|
* is not explicitly set in .env.
|
||||||
|
*/
|
||||||
|
public function handle($request, \Closure $next)
|
||||||
|
{
|
||||||
|
return parent::handle($request, function ($request) use ($next) {
|
||||||
|
// At this point proxy headers have been applied to the request,
|
||||||
|
// so $request->secure() correctly reflects the actual protocol.
|
||||||
|
if ($request->secure() && config('session.secure') === null) {
|
||||||
|
config(['session.secure' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd;
|
use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd;
|
||||||
use App\Actions\Stripe\RefundSubscription;
|
use App\Actions\Stripe\RefundSubscription;
|
||||||
use App\Actions\Stripe\ResumeSubscription;
|
use App\Actions\Stripe\ResumeSubscription;
|
||||||
|
use App\Actions\Stripe\UpdateSubscriptionQuantity;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
@ -14,6 +15,14 @@ class Actions extends Component
|
||||||
{
|
{
|
||||||
public $server_limits = 0;
|
public $server_limits = 0;
|
||||||
|
|
||||||
|
public int $quantity = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT;
|
||||||
|
|
||||||
|
public int $minServerLimit = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT;
|
||||||
|
|
||||||
|
public int $maxServerLimit = UpdateSubscriptionQuantity::MAX_SERVER_LIMIT;
|
||||||
|
|
||||||
|
public ?array $pricePreview = null;
|
||||||
|
|
||||||
public bool $isRefundEligible = false;
|
public bool $isRefundEligible = false;
|
||||||
|
|
||||||
public int $refundDaysRemaining = 0;
|
public int $refundDaysRemaining = 0;
|
||||||
|
|
@ -25,6 +34,46 @@ class Actions extends Component
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->server_limits = Team::serverLimit();
|
$this->server_limits = Team::serverLimit();
|
||||||
|
$this->quantity = (int) $this->server_limits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadPricePreview(int $quantity): void
|
||||||
|
{
|
||||||
|
$this->quantity = $quantity;
|
||||||
|
$result = (new UpdateSubscriptionQuantity)->fetchPricePreview(currentTeam(), $quantity);
|
||||||
|
$this->pricePreview = $result['success'] ? $result['preview'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password validation is intentionally skipped for quantity updates.
|
||||||
|
// Unlike refunds/cancellations, changing the server limit is a
|
||||||
|
// non-destructive, reversible billing adjustment (prorated by Stripe).
|
||||||
|
public function updateQuantity(string $password = ''): bool
|
||||||
|
{
|
||||||
|
if ($this->quantity < UpdateSubscriptionQuantity::MIN_SERVER_LIMIT) {
|
||||||
|
$this->dispatch('error', 'Minimum server limit is '.UpdateSubscriptionQuantity::MIN_SERVER_LIMIT.'.');
|
||||||
|
$this->quantity = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->quantity === (int) $this->server_limits) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = (new UpdateSubscriptionQuantity)->execute(currentTeam(), $this->quantity);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$this->server_limits = $this->quantity;
|
||||||
|
$this->pricePreview = null;
|
||||||
|
$this->dispatch('success', 'Server limit updated to '.$this->quantity.'.');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->dispatch('error', $result['error'] ?? 'Failed to update server limit.');
|
||||||
|
$this->quantity = (int) $this->server_limits;
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function loadRefundEligibility(): void
|
public function loadRefundEligibility(): void
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,20 @@ public function team()
|
||||||
return $this->belongsTo(Team::class);
|
return $this->belongsTo(Team::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function billingInterval(): string
|
||||||
|
{
|
||||||
|
if ($this->stripe_plan_id) {
|
||||||
|
$configKey = collect(config('subscription'))
|
||||||
|
->search($this->stripe_plan_id);
|
||||||
|
|
||||||
|
if ($configKey && str($configKey)->contains('yearly')) {
|
||||||
|
return 'yearly';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'monthly';
|
||||||
|
}
|
||||||
|
|
||||||
public function type()
|
public function type()
|
||||||
{
|
{
|
||||||
if (isStripe()) {
|
if (isStripe()) {
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
$skipPasswordConfirmation = shouldSkipPasswordConfirmation();
|
$skipPasswordConfirmation = shouldSkipPasswordConfirmation();
|
||||||
if ($temporaryDisableTwoStepConfirmation) {
|
if ($temporaryDisableTwoStepConfirmation) {
|
||||||
$disableTwoStepConfirmation = false;
|
$disableTwoStepConfirmation = false;
|
||||||
$skipPasswordConfirmation = false;
|
// Password confirmation requirement is not affected by temporary two-step disable
|
||||||
}
|
}
|
||||||
// When password step is skipped, Step 2 becomes final - change button text from "Continue" to "Confirm"
|
// When password step is skipped, Step 2 becomes final - change button text from "Continue" to "Confirm"
|
||||||
$effectiveStep2ButtonText = ($skipPasswordConfirmation && $step2ButtonText === 'Continue') ? 'Confirm' : $step2ButtonText;
|
$effectiveStep2ButtonText = ($skipPasswordConfirmation && $step2ButtonText === 'Continue') ? 'Confirm' : $step2ButtonText;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,39 @@
|
||||||
<div wire:init="loadRefundEligibility">
|
<div wire:init="loadRefundEligibility">
|
||||||
@if (subscriptionProvider() === 'stripe')
|
@if (subscriptionProvider() === 'stripe')
|
||||||
{{-- Plan Overview --}}
|
{{-- Plan Overview --}}
|
||||||
<section class="-mt-2">
|
<section x-data="{
|
||||||
|
qty: {{ $quantity }},
|
||||||
|
get current() { return $wire.server_limits; },
|
||||||
|
activeServers: {{ currentTeam()->servers->count() }},
|
||||||
|
preview: @js($pricePreview),
|
||||||
|
loading: false,
|
||||||
|
showModal: false,
|
||||||
|
async fetchPreview() {
|
||||||
|
if (this.qty < 2 || this.qty > 100 || this.qty === this.current) { return; }
|
||||||
|
this.loading = true;
|
||||||
|
this.preview = null;
|
||||||
|
await $wire.loadPricePreview(this.qty);
|
||||||
|
this.preview = $wire.pricePreview;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
fmt(cents) {
|
||||||
|
if (!this.preview) return '';
|
||||||
|
const c = this.preview.currency;
|
||||||
|
return c === 'USD' ? '$' + (cents / 100).toFixed(2) : (cents / 100).toFixed(2) + ' ' + c;
|
||||||
|
},
|
||||||
|
get isReduction() { return this.qty < this.activeServers; },
|
||||||
|
get hasChanged() { return this.qty !== this.current; },
|
||||||
|
get hasPreview() { return this.preview !== null; },
|
||||||
|
openAdjust() {
|
||||||
|
this.showModal = true;
|
||||||
|
},
|
||||||
|
closeAdjust() {
|
||||||
|
this.showModal = false;
|
||||||
|
this.qty = this.current;
|
||||||
|
this.preview = null;
|
||||||
|
}
|
||||||
|
}" @success.window="preview = null; showModal = false; qty = $wire.server_limits"
|
||||||
|
@keydown.escape.window="if (showModal) { closeAdjust(); }" class="-mt-2">
|
||||||
<h3 class="pb-2">Plan Overview</h3>
|
<h3 class="pb-2">Plan Overview</h3>
|
||||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
{{-- Current Plan Card --}}
|
{{-- Current Plan Card --}}
|
||||||
|
|
@ -25,11 +57,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Server Limit Card --}}
|
{{-- Paid Servers Card --}}
|
||||||
<div class="p-5 rounded border dark:bg-coolgray-100 bg-white border-neutral-200 dark:border-coolgray-400">
|
<div class="p-5 rounded border dark:bg-coolgray-100 bg-white border-neutral-200 dark:border-coolgray-400 cursor-pointer hover:border-warning/50 transition-colors"
|
||||||
|
@click="openAdjust()">
|
||||||
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1">Paid Servers</div>
|
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1">Paid Servers</div>
|
||||||
<div class="text-xl font-bold dark:text-white">{{ $server_limits }}</div>
|
<div class="text-xl font-bold dark:text-white" x-text="current"></div>
|
||||||
<div class="pt-2 text-sm text-neutral-500">Included in your plan</div>
|
<div class="pt-2 text-sm text-neutral-500">Click to adjust</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Active Servers Card --}}
|
{{-- Active Servers Card --}}
|
||||||
|
|
@ -49,103 +82,183 @@ class="p-5 rounded border dark:bg-coolgray-100 bg-white border-neutral-200 dark:
|
||||||
subscription. Excess servers will be deactivated.
|
subscription. Excess servers will be deactivated.
|
||||||
</x-callout>
|
</x-callout>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{-- Adjust Server Limit Modal --}}
|
||||||
|
<template x-teleport="body">
|
||||||
|
<div x-show="showModal"
|
||||||
|
class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-4" x-cloak>
|
||||||
|
<div x-show="showModal" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"
|
||||||
|
@click="closeAdjust()">
|
||||||
|
</div>
|
||||||
|
<div x-show="showModal" x-trap.inert.noscroll="showModal"
|
||||||
|
x-transition:enter="ease-out duration-100"
|
||||||
|
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
x-transition:leave="ease-in duration-100"
|
||||||
|
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||||
|
class="relative w-full border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col">
|
||||||
|
<div class="flex justify-between items-center py-6 px-7 shrink-0">
|
||||||
|
<h3 class="pr-8 text-2xl font-bold">Adjust Server Limit</h3>
|
||||||
|
<button @click="closeAdjust()"
|
||||||
|
class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
|
||||||
|
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||||
|
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-auto overflow-y-auto px-7 pb-6 space-y-4"
|
||||||
|
style="-webkit-overflow-scrolling: touch;">
|
||||||
|
{{-- Server count input --}}
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-bold text-neutral-500 uppercase tracking-wide">Paid Servers</label>
|
||||||
|
<div class="flex items-center gap-3 pt-1">
|
||||||
|
<input type="number" min="{{ $minServerLimit }}" max="{{ $maxServerLimit }}" step="1"
|
||||||
|
x-model.number="qty"
|
||||||
|
@input="preview = null"
|
||||||
|
@change="qty = Math.min({{ $maxServerLimit }}, Math.max({{ $minServerLimit }}, qty || {{ $minServerLimit }}))"
|
||||||
|
class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolgray-200 dark:border-coolgray-400 border-neutral-200 dark:text-white">
|
||||||
|
<x-forms.button
|
||||||
|
isHighlighted
|
||||||
|
x-bind:disabled="!hasChanged || loading"
|
||||||
|
@click="fetchPreview()">
|
||||||
|
Calculate Price
|
||||||
|
</x-forms.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Loading --}}
|
||||||
|
<div x-show="loading" x-cloak>
|
||||||
|
<x-loading text="Loading price preview..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Price Preview --}}
|
||||||
|
<div class="space-y-4" x-show="!loading && hasPreview" x-cloak>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">Due now</div>
|
||||||
|
<div class="flex justify-between gap-6 text-sm font-bold">
|
||||||
|
<span class="dark:text-white">Prorated charge</span>
|
||||||
|
<span class="dark:text-warning" x-text="fmt(preview.due_now)"></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-neutral-500 pt-1">Charged immediately to your payment method.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">Next billing cycle</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div class="flex justify-between gap-6 text-sm">
|
||||||
|
<span class="text-neutral-500" x-text="preview.quantity + ' servers × ' + fmt(preview.unit_price)"></span>
|
||||||
|
<span class="dark:text-white" x-text="fmt(preview.recurring_subtotal)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-6 text-sm" x-show="preview?.tax_description" x-cloak>
|
||||||
|
<span class="text-neutral-500" x-text="preview?.tax_description"></span>
|
||||||
|
<span class="dark:text-white" x-text="fmt(preview?.recurring_tax)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-6 text-sm font-bold pt-1.5 border-t dark:border-coolgray-400 border-neutral-200">
|
||||||
|
<span class="dark:text-white">Total / month</span>
|
||||||
|
<span class="dark:text-white" x-text="fmt(preview.recurring_total)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Update Button with Confirmation --}}
|
||||||
|
<x-modal-confirmation
|
||||||
|
title="Confirm Server Limit Update"
|
||||||
|
buttonTitle="Update Server Limit"
|
||||||
|
submitAction="updateQuantity"
|
||||||
|
:confirmWithText="false"
|
||||||
|
:confirmWithPassword="false"
|
||||||
|
:actions="[
|
||||||
|
'Your server limit will be updated immediately.',
|
||||||
|
'The prorated amount will be invoiced and charged now.',
|
||||||
|
]"
|
||||||
|
warningMessage="This will update your subscription and charge the prorated amount to your payment method."
|
||||||
|
step2ButtonText="Confirm & Pay">
|
||||||
|
<x-slot:content>
|
||||||
|
<x-forms.button @click="$wire.set('quantity', qty)">
|
||||||
|
Update Server Limit
|
||||||
|
</x-forms.button>
|
||||||
|
</x-slot:content>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Reduction Warning --}}
|
||||||
|
<div x-show="isReduction" x-cloak>
|
||||||
|
<x-callout type="danger" title="Warning">
|
||||||
|
Reducing below your active server count will deactivate excess servers.
|
||||||
|
</x-callout>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{-- Manage Plan --}}
|
{{-- Billing, Refund & Cancellation --}}
|
||||||
<section>
|
<section>
|
||||||
<h3 class="pb-2">Manage Plan</h3>
|
<h3 class="pb-2">Manage Subscription</h3>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="flex items-center gap-4">
|
{{-- Billing --}}
|
||||||
<x-forms.button class="gap-2" wire:click='stripeCustomerPortal'>
|
<x-forms.button class="gap-2" wire:click='stripeCustomerPortal'>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
|
||||||
stroke-width="1.5" stroke="currentColor">
|
stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z" />
|
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z" />
|
||||||
</svg>
|
</svg>
|
||||||
Manage Billing on Stripe
|
Manage Billing on Stripe
|
||||||
</x-forms.button>
|
</x-forms.button>
|
||||||
</div>
|
|
||||||
<p class="text-sm text-neutral-500">Change your server quantity, update payment methods, or view
|
{{-- Resume or Cancel --}}
|
||||||
invoices.</p>
|
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||||
|
<x-forms.button wire:click="resumeSubscription">Resume Subscription</x-forms.button>
|
||||||
|
@else
|
||||||
|
<x-modal-confirmation title="Cancel at End of Billing Period?"
|
||||||
|
buttonTitle="Cancel at Period End" submitAction="cancelAtPeriodEnd"
|
||||||
|
:actions="[
|
||||||
|
'Your subscription will remain active until the end of the current billing period.',
|
||||||
|
'No further charges will be made after the current period.',
|
||||||
|
'You can resubscribe at any time.',
|
||||||
|
]" confirmationText="{{ currentTeam()->name }}"
|
||||||
|
confirmationLabel="Enter your team name to confirm"
|
||||||
|
shortConfirmationLabel="Team Name" step2ButtonText="Confirm Cancellation" />
|
||||||
|
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
|
||||||
|
isErrorButton submitAction="cancelImmediately"
|
||||||
|
:actions="[
|
||||||
|
'Your subscription will be cancelled immediately.',
|
||||||
|
'All servers will be deactivated.',
|
||||||
|
'No refund will be issued for the remaining period.',
|
||||||
|
]" confirmationText="{{ currentTeam()->name }}"
|
||||||
|
confirmationLabel="Enter your team name to confirm"
|
||||||
|
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Refund --}}
|
||||||
|
@if ($refundCheckLoading)
|
||||||
|
<x-loading text="Checking refund..." />
|
||||||
|
@elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||||
|
<x-modal-confirmation title="Request Full Refund?" buttonTitle="Request Full Refund"
|
||||||
|
isErrorButton submitAction="refundSubscription"
|
||||||
|
:actions="[
|
||||||
|
'Your latest payment will be fully refunded.',
|
||||||
|
'Your subscription will be cancelled immediately.',
|
||||||
|
'All servers will be deactivated.',
|
||||||
|
]" confirmationText="{{ currentTeam()->name }}"
|
||||||
|
confirmationLabel="Enter your team name to confirm" shortConfirmationLabel="Team Name"
|
||||||
|
step2ButtonText="Confirm Refund & Cancel" />
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Contextual notes --}}
|
||||||
|
@if ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||||
|
<p class="mt-2 text-sm text-neutral-500">Eligible for a full refund — <strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining.</p>
|
||||||
|
@elseif ($refundAlreadyUsed)
|
||||||
|
<p class="mt-2 text-sm text-neutral-500">Refund already processed. Each team is eligible for one refund only.</p>
|
||||||
|
@endif
|
||||||
|
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||||
|
<p class="mt-2 text-sm text-neutral-500">Your subscription is set to cancel at the end of the billing period.</p>
|
||||||
|
@endif
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{-- Refund Section --}}
|
|
||||||
@if ($refundCheckLoading)
|
|
||||||
<section>
|
|
||||||
<h3 class="pb-2">Refund</h3>
|
|
||||||
<x-loading text="Checking refund eligibility..." />
|
|
||||||
</section>
|
|
||||||
@elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
|
|
||||||
<section>
|
|
||||||
<h3 class="pb-2">Refund</h3>
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<x-modal-confirmation title="Request Full Refund?" buttonTitle="Request Full Refund"
|
|
||||||
isErrorButton submitAction="refundSubscription"
|
|
||||||
:actions="[
|
|
||||||
'Your latest payment will be fully refunded.',
|
|
||||||
'Your subscription will be cancelled immediately.',
|
|
||||||
'All servers will be deactivated.',
|
|
||||||
]" confirmationText="{{ currentTeam()->name }}"
|
|
||||||
confirmationLabel="Enter your team name to confirm" shortConfirmationLabel="Team Name"
|
|
||||||
step2ButtonText="Confirm Refund & Cancel" />
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-neutral-500">You are eligible for a full refund.
|
|
||||||
<strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining
|
|
||||||
in the 30-day refund window.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@elseif ($refundAlreadyUsed)
|
|
||||||
<section>
|
|
||||||
<h3 class="pb-2">Refund</h3>
|
|
||||||
<p class="text-sm text-neutral-500">A refund has already been processed for this team. Each team is
|
|
||||||
eligible for one refund only to prevent abuse.</p>
|
|
||||||
</section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- Resume / Cancel Subscription Section --}}
|
|
||||||
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
|
|
||||||
<section>
|
|
||||||
<h3 class="pb-2">Resume Subscription</h3>
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<x-forms.button wire:click="resumeSubscription">Resume Subscription</x-forms.button>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-neutral-500">Your subscription is set to cancel at the end of the billing
|
|
||||||
period. Resume to continue your plan.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@else
|
|
||||||
<section>
|
|
||||||
<h3 class="pb-2">Cancel Subscription</h3>
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<x-modal-confirmation title="Cancel at End of Billing Period?"
|
|
||||||
buttonTitle="Cancel at Period End" submitAction="cancelAtPeriodEnd"
|
|
||||||
:actions="[
|
|
||||||
'Your subscription will remain active until the end of the current billing period.',
|
|
||||||
'No further charges will be made after the current period.',
|
|
||||||
'You can resubscribe at any time.',
|
|
||||||
]" confirmationText="{{ currentTeam()->name }}"
|
|
||||||
confirmationLabel="Enter your team name to confirm"
|
|
||||||
shortConfirmationLabel="Team Name" step2ButtonText="Confirm Cancellation" />
|
|
||||||
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
|
|
||||||
isErrorButton submitAction="cancelImmediately"
|
|
||||||
:actions="[
|
|
||||||
'Your subscription will be cancelled immediately.',
|
|
||||||
'All servers will be deactivated.',
|
|
||||||
'No refund will be issued for the remaining period.',
|
|
||||||
]" confirmationText="{{ currentTeam()->name }}"
|
|
||||||
confirmationLabel="Enter your team name to confirm"
|
|
||||||
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-neutral-500">Cancel your subscription immediately or at the end of the
|
|
||||||
current billing period.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="text-sm text-neutral-500">
|
<div class="text-sm text-neutral-500">
|
||||||
Need help? <a class="underline dark:text-white" href="{{ config('constants.urls.contact') }}"
|
Need help? <a class="underline dark:text-white" href="{{ config('constants.urls.contact') }}"
|
||||||
target="_blank">Contact us.</a>
|
target="_blank">Contact us.</a>
|
||||||
|
|
|
||||||
64
tests/Feature/SecureCookieAutoDetectionTest.php
Normal file
64
tests/Feature/SecureCookieAutoDetectionTest.php
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Cache::forget('instance_settings_fqdn_host');
|
||||||
|
InstanceSettings::updateOrCreate(['id' => 0], ['fqdn' => null]);
|
||||||
|
// Ensure session.secure starts unconfigured for each test
|
||||||
|
config(['session.secure' => null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets session.secure to true when request arrives over HTTPS via proxy', function () {
|
||||||
|
$this->get('/login', [
|
||||||
|
'X-Forwarded-Proto' => 'https',
|
||||||
|
'X-Forwarded-For' => '1.2.3.4',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(config('session.secure'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set session.secure for plain HTTP requests', function () {
|
||||||
|
$this->get('/login');
|
||||||
|
|
||||||
|
expect(config('session.secure'))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not override explicit SESSION_SECURE_COOKIE=false for HTTPS requests', function () {
|
||||||
|
config(['session.secure' => false]);
|
||||||
|
|
||||||
|
$this->get('/login', [
|
||||||
|
'X-Forwarded-Proto' => 'https',
|
||||||
|
'X-Forwarded-For' => '1.2.3.4',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Explicit false must not be overridden — our check is `=== null` only
|
||||||
|
expect(config('session.secure'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not override explicit SESSION_SECURE_COOKIE=true', function () {
|
||||||
|
config(['session.secure' => true]);
|
||||||
|
|
||||||
|
$this->get('/login');
|
||||||
|
|
||||||
|
expect(config('session.secure'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks session cookie with Secure flag when accessed over HTTPS proxy', function () {
|
||||||
|
$response = $this->get('/login', [
|
||||||
|
'X-Forwarded-Proto' => 'https',
|
||||||
|
'X-Forwarded-For' => '1.2.3.4',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSuccessful();
|
||||||
|
|
||||||
|
$cookieName = config('session.cookie');
|
||||||
|
$sessionCookie = collect($response->headers->all('set-cookie'))
|
||||||
|
->first(fn ($c) => str_contains($c, $cookieName));
|
||||||
|
|
||||||
|
expect($sessionCookie)->not->toBeNull()
|
||||||
|
->and(strtolower($sessionCookie))->toContain('; secure');
|
||||||
|
});
|
||||||
|
|
@ -286,6 +286,56 @@
|
||||||
expect($response->status())->not->toBe(400);
|
expect($response->status())->not->toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('trusts localhost when FQDN is configured', function () {
|
||||||
|
InstanceSettings::updateOrCreate(
|
||||||
|
['id' => 0],
|
||||||
|
['fqdn' => 'https://coolify.example.com']
|
||||||
|
);
|
||||||
|
|
||||||
|
$middleware = new TrustHosts($this->app);
|
||||||
|
$hosts = $middleware->hosts();
|
||||||
|
|
||||||
|
expect($hosts)->toContain('localhost');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trusts 127.0.0.1 when FQDN is configured', function () {
|
||||||
|
InstanceSettings::updateOrCreate(
|
||||||
|
['id' => 0],
|
||||||
|
['fqdn' => 'https://coolify.example.com']
|
||||||
|
);
|
||||||
|
|
||||||
|
$middleware = new TrustHosts($this->app);
|
||||||
|
$hosts = $middleware->hosts();
|
||||||
|
|
||||||
|
expect($hosts)->toContain('127.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trusts IPv6 loopback when FQDN is configured', function () {
|
||||||
|
InstanceSettings::updateOrCreate(
|
||||||
|
['id' => 0],
|
||||||
|
['fqdn' => 'https://coolify.example.com']
|
||||||
|
);
|
||||||
|
|
||||||
|
$middleware = new TrustHosts($this->app);
|
||||||
|
$hosts = $middleware->hosts();
|
||||||
|
|
||||||
|
expect($hosts)->toContain('[::1]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () {
|
||||||
|
InstanceSettings::updateOrCreate(
|
||||||
|
['id' => 0],
|
||||||
|
['fqdn' => 'https://coolify.example.com']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->get('/', [
|
||||||
|
'Host' => 'localhost',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Should NOT be rejected as untrusted host (would be 400)
|
||||||
|
expect($response->status())->not->toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
it('skips host validation for webhook endpoints', function () {
|
it('skips host validation for webhook endpoints', function () {
|
||||||
// All webhook routes are under /webhooks/* prefix (see RouteServiceProvider)
|
// All webhook routes are under /webhooks/* prefix (see RouteServiceProvider)
|
||||||
// and use cryptographic signature validation instead of host validation
|
// and use cryptographic signature validation instead of host validation
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue