Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117825991
D5122.1775299130.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
56 KB
Referenced Files
None
Subscribers
None
D5122.1775299130.diff
View Options
diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -9,14 +9,16 @@
use App\Plan;
use App\Providers\PaymentProvider;
use App\ReferralCode;
-use App\Rules\SignupExternalEmail;
-use App\Rules\SignupToken as SignupTokenRule;
use App\Rules\Password;
use App\Rules\ReferralCode as ReferralCodeRule;
+use App\Rules\SignupExternalEmail;
+use App\Rules\SignupToken as SignupTokenRule;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\SignupCode;
use App\SignupInvitation;
+use App\SignupToken;
+use App\Tenant;
use App\User;
use App\Utils;
use App\VatRate;
@@ -92,11 +94,12 @@
'first_name' => 'max:128',
'last_name' => 'max:128',
'voucher' => 'max:32',
+ 'plan' => 'required',
];
- $plan = $this->getPlan();
+ $plan = $this->getPlan($request);
- if ($plan->mode == Plan::MODE_TOKEN) {
+ if ($plan?->mode == Plan::MODE_TOKEN) {
$rules['token'] = ['required', 'string', new SignupTokenRule($plan)];
} else {
$rules['email'] = ['required', 'string', new SignupExternalEmail()];
@@ -124,15 +127,13 @@
'status' => 'success',
'code' => $code->code,
'mode' => $plan->mode ?: 'email',
+ 'domains' => Domain::getPublicDomains(),
+ 'is_domain' => $plan->hasDomain(),
];
if ($plan->mode == Plan::MODE_TOKEN) {
// Token verification, jump to the last step
- $has_domain = $plan->hasDomain();
-
$response['short_code'] = $code->short_code;
- $response['is_domain'] = $has_domain;
- $response['domains'] = $has_domain ? [] : Domain::getPublicDomains();
} else {
// External email verification, send an email message
SignupVerificationJob::dispatch($code);
@@ -156,18 +157,11 @@
return $this->errorResponse(404);
}
- $has_domain = $this->getPlan()->hasDomain();
-
- $result = [
- 'id' => $id,
- 'is_domain' => $has_domain,
- 'domains' => $has_domain ? [] : Domain::getPublicDomains(),
- ];
+ $result = ['id' => $id];
return response()->json($result);
}
-
/**
* Validation of the verification code.
*
@@ -199,21 +193,26 @@
|| $code->isExpired()
|| Str::upper($request->short_code) !== Str::upper($code->short_code)
) {
- $errors = ['short_code' => "The code is invalid or expired."];
+ $errors = ['short_code' => self::trans('validation.signupcodeinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// For signup last-step mode remember the code object, so we can delete it
// with single SQL query (->delete()) instead of two
- $request->code = $code;
+ $request->merge(['code' => $code]);
+
+ $plan = $this->getPlan($request);
+
+ if (!$plan) {
+ $errors = ['short_code' => self::trans('validation.signupcodeinvalid')];
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
if ($update) {
$code->verify_ip_address = $request->ip();
$code->save();
}
- $has_domain = $this->getPlan()->hasDomain();
-
// Return user name and email/phone/voucher from the codes database,
// domains list for selection and "plan type" flag
return response()->json([
@@ -222,8 +221,7 @@
'first_name' => $code->first_name,
'last_name' => $code->last_name,
'voucher' => $code->voucher,
- 'is_domain' => $has_domain,
- 'domains' => $has_domain ? [] : Domain::getPublicDomains(),
+ 'is_domain' => $plan->hasDomain(),
]);
}
@@ -243,10 +241,28 @@
'voucher' => 'max:32',
];
+ if ($request->invitation) {
+ // Signup via invitation
+ $invitation = SignupInvitation::withEnvTenantContext()->find($request->invitation);
+
+ if (empty($invitation) || $invitation->isCompleted()) {
+ return $this->errorResponse(404);
+ }
+
+ // Check optional fields
+ $rules['first_name'] = 'max:128';
+ $rules['last_name'] = 'max:128';
+ }
+
+ if (!$request->code) {
+ $rules['plan'] = 'required';
+ }
+
+ $plan = $this->getPlan($request);
+
// Direct signup by token
if ($request->token) {
// This will validate the token and the plan mode
- $plan = $request->plan ? Plan::withEnvTenantContext()->where('title', $request->plan)->first() : null;
$rules['token'] = ['required', 'string', new SignupTokenRule($plan)];
}
@@ -259,44 +275,16 @@
$settings = [];
- if (!empty($request->token)) {
+ if ($request->token) {
$settings = ['signup_token' => strtoupper($request->token)];
- } elseif (!empty($request->plan) && empty($request->code) && empty($request->invitation)) {
- // Plan parameter is required/allowed in mandate mode
- $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first();
-
- if (!$plan || $plan->mode != Plan::MODE_MANDATE) {
- $msg = self::trans('validation.exists', ['attribute' => 'plan']);
- return response()->json(['status' => 'error', 'errors' => ['plan' => $msg]], 422);
- }
- } elseif ($request->invitation) {
- // Signup via invitation
- $invitation = SignupInvitation::withEnvTenantContext()->find($request->invitation);
-
- if (empty($invitation) || $invitation->isCompleted()) {
- return $this->errorResponse(404);
- }
-
- // Check required fields
- $v = Validator::make(
- $request->all(),
- [
- 'first_name' => 'max:128',
- 'last_name' => 'max:128',
- ]
- );
-
- $errors = $v->fails() ? $v->errors()->toArray() : [];
-
- if (!empty($errors)) {
- return response()->json(['status' => 'error', 'errors' => $errors], 422);
- }
-
+ } elseif (!empty($invitation)) {
$settings = [
'external_email' => $invitation->email,
'first_name' => $request->first_name,
'last_name' => $request->last_name,
];
+ } elseif (!$request->code && $plan?->mode == Plan::MODE_MANDATE) {
+ // mandate mode
} else {
// Validate verification codes (again)
$v = $this->verify($request, false);
@@ -304,8 +292,6 @@
return $v;
}
- $plan = $this->getPlan();
-
// Get user name/email from the verification code database
$code_data = $v->getData();
@@ -314,6 +300,8 @@
'last_name' => $code_data->last_name,
];
+ $plan = $this->getPlan($request);
+
if ($plan->mode == Plan::MODE_TOKEN) {
$settings['signup_token'] = strtoupper($code_data->email);
} else {
@@ -332,10 +320,6 @@
}
}
- if (empty($plan)) {
- $plan = $this->getPlan();
- }
-
$is_domain = $plan->hasDomain();
// Validate login
@@ -345,7 +329,6 @@
// Set some properties for signup() method
$request->settings = $settings;
- $request->plan = $plan;
$request->discount = $discount ?? null;
$request->invitation = $invitation ?? null;
@@ -391,7 +374,7 @@
DB::beginTransaction();
// Create domain record
- if ($is_domain) {
+ if ($is_domain && !Domain::withTrashed()->where('namespace', $domain_name)->exists()) {
$domain = Domain::create([
'namespace' => $domain_name,
'type' => Domain::TYPE_EXTERNAL,
@@ -452,7 +435,7 @@
// Bump up counter on the signup token
if (!empty($request->settings['signup_token'])) {
- \App\SignupToken::where('id', $request->settings['signup_token'])->increment('counter');
+ SignupToken::where('id', $request->settings['signup_token'])->increment('counter');
}
DB::commit();
@@ -508,7 +491,7 @@
$mandate = [
'currency' => $wallet->currency,
- 'description' => \App\Tenant::getConfig($user->tenant_id, 'app.name')
+ 'description' => Tenant::getConfig($user->tenant_id, 'app.name')
. ' ' . self::trans('app.mandate-description-suffix'),
'methodId' => PaymentProvider::METHOD_CREDITCARD,
@@ -576,13 +559,15 @@
/**
* Returns plan for the signup process
*
+ * @param \Illuminate\Http\Request $request HTTP request
+ *
* @returns \App\Plan Plan object selected for current signup process
*/
- protected function getPlan()
+ protected function getPlan(Request $request)
{
- $request = request();
+ if (!$request->plan instanceof Plan) {
+ $plan = null;
- if (!$request->plan || !$request->plan instanceof Plan) {
// Get the plan if specified and exists...
if (($request->code instanceof SignupCode) && $request->code->plan) {
$plan = Plan::withEnvTenantContext()->where('title', $request->code->plan)->first();
@@ -590,13 +575,7 @@
$plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first();
}
- // ...otherwise use the default plan
- if (empty($plan)) {
- // TODO: Get default plan title from config
- $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
- }
-
- $request->plan = $plan;
+ $request->merge(['plan' => $plan]);
}
return $request->plan;
@@ -605,13 +584,13 @@
/**
* Login (kolab identity) validation
*
- * @param string $login Login (local part of an email address)
- * @param string $domain Domain name
- * @param bool $external Enables additional checks for domain part
+ * @param string $login Login (local part of an email address)
+ * @param string $namespace Domain name
+ * @param bool $external Enable signup for a custom domain
*
* @return array Error messages on validation error
*/
- protected static function validateLogin($login, $domain, $external = false): ?array
+ protected static function validateLogin($login, $namespace, $external = false): ?array
{
// Validate login part alone
$v = Validator::make(
@@ -623,29 +602,35 @@
return ['login' => $v->errors()->toArray()['login'][0]];
}
- $domains = $external ? null : Domain::getPublicDomains();
-
- // Validate the domain
- $v = Validator::make(
- ['domain' => $domain],
- ['domain' => ['required', 'string', new UserEmailDomain($domains)]]
- );
-
- if ($v->fails()) {
- return ['domain' => $v->errors()->toArray()['domain'][0]];
+ $domain = null;
+ if (is_string($namespace)) {
+ $namespace = Str::lower($namespace);
+ $domain = Domain::withTrashed()->where('namespace', $namespace)->first();
}
- $domain = Str::lower($domain);
+ if ($domain && $domain->isPublic() && !$domain->trashed()) {
+ // no error, everyone can signup for an existing public domain
+ } elseif ($domain) {
+ // domain exists and is not public (or is deleted)
+ return ['domain' => self::trans('validation.domainnotavailable')];
+ } else {
+ // non-existing custom domain
+ if (!$external) {
+ return ['domain' => self::trans('validation.domaininvalid')];
+ }
+
+ $v = Validator::make(
+ ['domain' => $namespace],
+ ['domain' => ['required', 'string', new UserEmailDomain()]]
+ );
- // Check if domain is already registered with us
- if ($external) {
- if (Domain::withTrashed()->where('namespace', $domain)->exists()) {
- return ['domain' => self::trans('validation.domainexists')];
+ if ($v->fails()) {
+ return ['domain' => $v->errors()->toArray()['domain'][0]];
}
}
// Check if user with specified login already exists
- $email = $login . '@' . $domain;
+ $email = $login . '@' . $namespace;
if (User::emailExists($email) || User::aliasExists($email) || \App\Group::emailExists($email)) {
return ['login' => self::trans('validation.loginexists')];
}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -7,7 +7,6 @@
use App\License;
use App\Plan;
use App\Rules\Password;
-use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\Sku;
use App\User;
@@ -248,8 +247,7 @@
$result = [
'skus' => $skus,
'enableBeta' => $hasBeta,
- // TODO: This will change when we enable all users to create domains
- 'enableDomains' => $isController && $hasCustomDomain,
+ 'enableDomains' => $isController && ($hasCustomDomain || $plan?->hasDomain()),
'enableDistlists' => $isController && $hasCustomDomain && \config('app.with_distlists'),
'enableFiles' => !$isDegraded && $hasBeta && \config('app.with_files'),
'enableFolders' => $isController && $hasCustomDomain && \config('app.with_shared_folders'),
@@ -260,7 +258,7 @@
'enableUsers' => $isController,
'enableWallets' => $isController && \config('app.with_wallet'),
'enableWalletMandates' => $isController,
- 'enableWalletPayments' => $isController && (!$plan || $plan->mode != Plan::MODE_MANDATE),
+ 'enableWalletPayments' => $isController && $plan?->mode != Plan::MODE_MANDATE,
'enableCompanionapps' => $hasBeta && \config('app.with_companion_app'),
];
diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php
--- a/src/app/Observers/EntitlementObserver.php
+++ b/src/app/Observers/EntitlementObserver.php
@@ -33,13 +33,11 @@
return false;
}
- $sku = \App\Sku::find($entitlement->sku_id);
-
- if (!$sku) {
+ if (empty($entitlement->sku)) {
return false;
}
- $result = $sku->handler_class::preReq($entitlement, $wallet->owner);
+ $result = $entitlement->sku->handler_class::preReq($entitlement, $wallet->owner);
if (!$result) {
return false;
diff --git a/src/app/Plan.php b/src/app/Plan.php
--- a/src/app/Plan.php
+++ b/src/app/Plan.php
@@ -23,7 +23,6 @@
* @property string $id
* @property string $mode Plan signup mode (Plan::MODE_*)
* @property string $name
- * @property \App\Package[] $packages
* @property datetime $promo_from
* @property datetime $promo_to
* @property ?int $tenant_id
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -150,22 +150,32 @@
* @param \App\Domain $domain Optional domain object
*
* @return \App\User Self
+ * @throws \Exception
*/
public function assignPlan($plan, $domain = null): User
{
- $this->setSetting('plan_id', $plan->id);
+ $domain_packages = $plan->packages->filter(function ($package) {
+ return $package->isDomain();
+ });
- foreach ($plan->packages as $package) {
- if ($package->isDomain()) {
- if (!$domain) {
- throw new \Exception("Attempted to assign a domain package without passing a domain.");
- }
+ // Before we do anything let's make sure that a custom domain can be assigned only
+ // to a plan with a domain package
+ if ($domain && $domain_packages->isEmpty()) {
+ throw new \Exception("Custom domain requires a plan with a domain SKU");
+ }
+
+ foreach ($plan->packages->diff($domain_packages) as $package) {
+ $this->assignPackage($package);
+ }
+
+ if ($domain) {
+ foreach ($domain_packages as $package) {
$domain->assignPackage($package, $this);
- } else {
- $this->assignPackage($package);
}
}
+ $this->setSetting('plan_id', $plan->id);
+
return $this;
}
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -356,6 +356,12 @@
// Add 'class' attribute to the body, different for each page
// so, we can apply page-specific styles
document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '').toLowerCase()
+ },
+ updateStatusInfo() {
+ axios.get('/api/v4/users/' + this.authInfo.id + '/status')
+ .then(response => {
+ this.authInfo.statusInfo = response.data
+ })
}
}
})
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -436,6 +436,7 @@
'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.",
'step3' => "Create your {app} identity (you can choose additional addresses later).",
'created' => "The account is about to be created!",
+ 'owndomain' => "Use your own domain",
'token' => "Signup authorization token",
'voucher' => "Voucher Code",
],
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -176,6 +176,7 @@
'password-policy-last-error' => 'The minimum value for last N passwords is :last.',
'referralcodeinvalid' => 'The referral program code is invalid.',
'signuptokeninvalid' => 'The signup token is invalid.',
+ 'signupcodeinvalid' => 'The verification code is invalid or expired.',
/*
|--------------------------------------------------------------------------
diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue
--- a/src/resources/vue/Domain/Info.vue
+++ b/src/resources/vue/Domain/Info.vue
@@ -162,6 +162,7 @@
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'domains' })
+ this.$root.updateStatusInfo()
}
})
},
@@ -179,6 +180,7 @@
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'domains' })
+ this.$root.updateStatusInfo()
})
},
submitSettings() {
diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue
--- a/src/resources/vue/Signup.vue
+++ b/src/resources/vue/Signup.vue
@@ -1,6 +1,6 @@
<template>
<div class="container">
- <div id="step0" v-if="!invitation">
+ <div id="step0">
<div class="plan-selector row row-cols-sm-2 g-3">
<div v-for="item in plans" :key="item.id" :id="'plan-' + item.title">
<div :class="'card bg-light plan-' + item.title">
@@ -39,7 +39,7 @@
<label for="signup_email" class="visually-hidden">{{ $t('signup.email') }}</label>
<input type="text" class="form-control" id="signup_email" :placeholder="$t('signup.email')" required v-model="email">
</div>
- <btn class="btn-secondary" @click="stepBack">{{ $t('btn.back') }}</btn>
+ <btn class="btn-secondary" @click="stepBack" v-if="plans.length > 1">{{ $t('btn.back') }}</btn>
<btn class="btn-primary ms-2" type="submit" icon="check">{{ $t('btn.continue') }}</btn>
</form>
</div>
@@ -78,10 +78,14 @@
</div>
<div class="mb-3">
<label for="signup_login" class="visually-hidden"></label>
+ <div v-if="is_domain" class="form-check float-end text-secondary">
+ <input class="form-check-input" type="checkbox" value="1" id="custom_domain" @change="useCustomDomain">
+ <label class="form-check-label" for="custom_domain">{{ $t('signup.owndomain') }}</label>
+ </div>
<div class="input-group">
<input type="text" class="form-control" id="signup_login" required v-model="login" :placeholder="$t('signup.login')">
<span class="input-group-text">@</span>
- <input v-if="is_domain" type="text" class="form-control rounded-end" id="signup_domain" required v-model="domain" :placeholder="$t('form.domain')">
+ <input v-if="use_custom" type="text" class="form-control rounded-end" id="signup_domain" required :placeholder="$t('form.domain')" v-model="domain">
<select v-else class="form-select rounded-end" id="signup_domain" required v-model="domain">
<option v-for="_domain in domains" :key="_domain" :value="_domain">{{ _domain }}</option>
</select>
@@ -92,7 +96,7 @@
<label for="signup_voucher" class="visually-hidden">{{ $t('signup.voucher') }}</label>
<input type="text" class="form-control" id="signup_voucher" :placeholder="$t('signup.voucher')" v-model="voucher">
</div>
- <btn v-if="!invitation" class="btn-secondary me-2" @click="stepBack">{{ $t('btn.back') }}</btn>
+ <btn v-if="!invitation || plans.length > 1" class="btn-secondary me-2" @click="stepBack">{{ $t('btn.back') }}</btn>
<btn class="btn-primary" type="submit" icon="check">
<span v-if="invitation">{{ $t('btn.signup') }}</span>
<span v-else>{{ $t('btn.submit') }}</span>
@@ -176,7 +180,9 @@
},
plans: [],
referral: '',
+ selectedDomain: '',
token: '',
+ use_custom: false,
voucher: ''
}
},
@@ -201,14 +207,7 @@
axios.get('/api/auth/signup/invitations/' + params[1], { loader: true })
.then(response => {
this.invitation = response.data
- this.login = response.data.login
- this.voucher = response.data.voucher
- this.first_name = response.data.first_name
- this.last_name = response.data.last_name
- this.plan = response.data.plan
- this.is_domain = response.data.is_domain
- this.setDomain(response.data)
- this.displayForm(3, true)
+ this.displayForm(0)
})
.catch(error => {
this.$root.errorHandler(error)
@@ -247,29 +246,20 @@
this.plan = title
this.mode = plan.mode
this.is_domain = plan.isDomain
- this.domain = ''
-
- let step = 1
-
- if (plan.mode == 'mandate') {
- step = 3
- if (!plan.isDomain || !this.domains.length) {
- axios.get('/api/auth/signup/domains')
- .then(response => {
- this.displayForm(step, true)
- this.setDomain(response.data)
- })
- return
- }
- }
- this.displayForm(step, true)
+ this.displayForm(plan.mode == 'mandate' || this.invitation ? 3 : 1, true)
}
},
step0(plan) {
if (!this.plans.length) {
axios.get('/api/auth/signup/plans', { loader: true }).then(response => {
this.plans = response.data.plans
+ if (this.plans.length == 1) {
+ if (!plan || plan != this.plans[0].title) {
+ plan = this.plans[0].title
+ }
+ }
+
this.selectPlanByTitle(plan)
})
.catch(error => {
@@ -291,12 +281,8 @@
this.short_code = response.data.short_code
this.mode = response.data.mode
this.is_domain = response.data.is_domain
+ this.setDomain(response.data)
this.displayForm(this.mode == 'token' ? 3 : 2, true)
-
- // Fill the domain selector with available domains
- if (!this.is_domain) {
- this.setDomain(response.data)
- }
})
},
// Submits the code to the API for verification
@@ -311,19 +297,13 @@
axios.post('/api/auth/signup/verify', post)
.then(response => {
- this.displayForm(3, true)
// Reset user name/email/plan, we don't have them if user used a verification link
this.first_name = response.data.first_name
this.last_name = response.data.last_name
this.email = response.data.email
this.is_domain = response.data.is_domain
this.voucher = response.data.voucher
- this.domain = ''
-
- // Fill the domain selector with available domains
- if (!this.is_domain) {
- this.setDomain(response.data)
- }
+ this.displayForm(3, true)
})
.catch(error => {
if (bylink === true) {
@@ -366,7 +346,7 @@
const card = $(e.target).closest('.card[id^="step"]')
let step = card.attr('id').replace('step', '')
- card.addClass('d-none').find('form')[0].reset()
+ card.addClass('d-none')
step -= 1
@@ -374,7 +354,7 @@
step = 1
}
- if (this.mode == 'mandate' && step < 3) {
+ if ((this.invitation || this.mode == 'mandate') && step < 3) {
step = 0
}
@@ -394,10 +374,19 @@
return this.step0()
}
- $('#step' + step).removeClass('d-none').find('form')[0].reset()
+ if (step > 2 && !this.domains.length) {
+ axios.get('/api/auth/signup/domains')
+ .then(response => {
+ this.setDomain(response.data)
+ this.displayForm(step, focus)
+ })
+ return
+ }
+
+ $('#step' + step).removeClass('d-none')
if (focus) {
- $('#step' + step).find('input').first().focus()
+ $('#step' + step).find('input:not([type=checkbox])').first().focus()
}
},
lastStepPostData() {
@@ -430,6 +419,12 @@
this.domain = this.domains[0]
}
}
+
+ this.selectedDomain = this.domain
+ },
+ useCustomDomain(event) {
+ this.use_custom = event.target.checked
+ this.domain = this.use_custom ? '' : this.selectedDomain
}
}
}
diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php
--- a/src/tests/Browser/DomainTest.php
+++ b/src/tests/Browser/DomainTest.php
@@ -3,6 +3,7 @@
namespace Tests\Browser;
use App\Domain;
+use App\Plan;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
@@ -22,7 +23,9 @@
public function setUp(): void
{
parent::setUp();
+
$this->deleteTestDomain('testdomain.com');
+ $this->deleteTestUser('testuserdomain@' . \config('app.domain'));
}
/**
@@ -31,6 +34,8 @@
public function tearDown(): void
{
$this->deleteTestDomain('testdomain.com');
+ $this->deleteTestUser('testuserdomain@' . \config('app.domain'));
+
parent::tearDown();
}
@@ -217,8 +222,8 @@
*/
public function testDomainListEmpty(): void
{
+ // User is not a wallet controller
$this->browse(function ($browser) {
- // Login the user
$browser->visit('/login')
->on(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
@@ -227,15 +232,51 @@
->assertMissing('@links a.link-domains')
->assertMissing('@links a.link-users')
->assertMissing('@links a.link-wallet');
-/*
- // On dashboard click the "Domains" link
- ->assertSeeIn('@links a.link-domains', 'Domains')
+ });
+
+ // A group account without a custom domain
+ $user = $this->getTestUser('testuserdomain@' . \config('app.domain'), ['password' => 'simple123']);
+ $plan = Plan::withObjectTenantContext($user)->where('title', 'group')->first();
+ $user->setSetting('plan_id', $plan->id);
+
+ $this->browse(function ($browser) use ($user) {
+ $browser->visit('/login')
+ ->on(new Home())
+ ->submitLogon($user->email, 'simple123', true)
+ ->on(new Dashboard())
+ ->assertVisible('@links a.link-settings')
+ ->assertVisible('@links a.link-domains')
+ ->assertMissing('@links a.link-shared-folders')
->click('@links a.link-domains')
- // On Domains List page click the domain entry
+ // We'll create/delete a domain and make sure new Dashboard items appear after that.
+ // More precise tests on the domain creation/deletion and list pages are in another place
->on(new DomainList())
- ->assertMissing('@table tbody')
- ->assertSeeIn('tfoot td', 'There are no domains in this account.');
-*/
+ ->click('.card-title button.btn-success')
+ ->on(new DomainInfo())
+ ->type('@general #namespace', 'testdomain.com')
+ ->click('button[type=submit]')
+ ->on(new DomainList())
+ ->assertSeeIn('@table tr:nth-child(1) a', 'testdomain.com')
+ ->click('a.link-dashboard')
+ ->on(new Dashboard())
+ ->assertVisible('@links a.link-domains')
+ ->assertVisible('@links a.link-shared-folders')
+ ->click('@links a.link-domains')
+ ->on(new DomainList())
+ ->click('@table tbody tr:nth-child(1) a')
+ ->on(new DomainInfo())
+ ->waitFor('button.button-delete')
+ ->click('button.button-delete')
+ ->with(new Dialog('#delete-warning'), function ($browser) {
+ $browser->click('@button-action');
+ })
+ ->waitUntilMissing('#delete-warning')
+ ->on(new DomainList())
+ ->assertElementsCount('@table tbody tr', 0)
+ ->click('a.link-dashboard')
+ ->on(new Dashboard())
+ ->assertVisible('@links a.link-domains')
+ ->assertMissing('@links a.link-shared-folders');
});
}
diff --git a/src/tests/Browser/Pages/DomainList.php b/src/tests/Browser/Pages/DomainList.php
--- a/src/tests/Browser/Pages/DomainList.php
+++ b/src/tests/Browser/Pages/DomainList.php
@@ -25,7 +25,7 @@
*/
public function assert($browser)
{
- $browser->assertPathIs($this->url())
+ $browser->waitForLocation($this->url())
->waitUntilMissing('@app .app-loader')
->assertSeeIn('@list .card-title', 'Domains');
}
diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php
--- a/src/tests/Browser/SignupTest.php
+++ b/src/tests/Browser/SignupTest.php
@@ -34,6 +34,7 @@
$this->deleteTestDomain('user-domain-signup.com');
Plan::whereNot('mode', Plan::MODE_EMAIL)->update(['mode' => Plan::MODE_EMAIL]);
+ Plan::withEnvTenantContext()->where('title', 'individual')->update(['hidden' => false]);
SignupToken::truncate();
ReferralProgram::query()->delete();
}
@@ -49,6 +50,7 @@
SignupInvitation::truncate();
Plan::whereNot('mode', Plan::MODE_EMAIL)->update(['mode' => Plan::MODE_EMAIL]);
+ Plan::withEnvTenantContext()->where('title', 'individual')->update(['hidden' => false]);
SignupToken::truncate();
ReferralProgram::query()->delete();
@@ -323,6 +325,8 @@
->assertVisible('#signup_password')
->assertVisible('#signup_password_confirmation')
->assertVisible('select#signup_domain')
+ ->assertMissing('#custom_domain')
+ ->assertMissing('input#signup_domain')
->assertElementsCount('select#signup_domain option', $domains_count, false)
->assertText('select#signup_domain option:nth-child(1)', $domains[0])
->assertValue('select#signup_domain option:nth-child(1)', $domains[0])
@@ -456,15 +460,28 @@
->click('[type=submit]');
});
- // Here we expect 4 text inputs, Back and Continue buttons
+ // Here we expect text inputs, as well as domain selector, and buttons
$browser->whenAvailable('@step3', function ($step) {
+ $domains = Domain::getPublicDomains();
+ $domains_count = count($domains);
+
$step->assertVisible('#signup_login')
->assertVisible('#signup_password')
->assertVisible('#signup_password_confirmation')
- ->assertVisible('input#signup_domain')
+ ->assertVisible('select#signup_domain')
+ ->assertMissing('input#signup_domain')
+ ->assertVisible('#custom_domain')
+ ->assertElementsCount('select#signup_domain option', $domains_count, false)
+ ->assertText('select#signup_domain option:nth-child(1)', $domains[0])
+ ->assertValue('select#signup_domain option:nth-child(1)', $domains[0])
+ ->assertText('select#signup_domain option:nth-child(2)', $domains[1])
+ ->assertValue('select#signup_domain option:nth-child(2)', $domains[1])
->assertVisible('[type=button]')
->assertVisible('[type=submit]')
->assertFocused('#signup_login')
+ ->click('#custom_domain + label')
+ ->assertVisible('input#signup_domain')
+ ->assertMissing('select#signup_domain')
->assertValue('input#signup_domain', '')
->assertValue('#signup_login', '')
->assertValue('#signup_password', '')
@@ -473,8 +490,7 @@
// Submit invalid login and password data
$browser->with('@step3', function ($step) {
- $step->assertFocused('#signup_login')
- ->type('#signup_login', '*')
+ $step->type('#signup_login', '*')
->type('#signup_domain', 'test.com')
->type('#signup_password', '12345678')
->type('#signup_password_confirmation', '123456789')
@@ -524,6 +540,71 @@
});
}
+ /**
+ * Test more signup cases
+ */
+ public function testSignupGroupWithPublicDomain(): void
+ {
+ // We also test skipping plan selection if there's only one plan active
+ $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
+ $plan->hidden = true;
+ $plan->save();
+
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Signup());
+
+ // Submit valid data
+ $browser->whenAvailable('@step1', function ($step) {
+ $step->type('#signup_first_name', 'Test')
+ ->type('#signup_last_name', 'User')
+ ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org')
+ ->click('[type=submit]');
+ });
+
+ // Submit valid code
+ $browser->whenAvailable('@step2', function ($step) {
+ // Get the code and short_code from database
+ $code = $step->value('#signup_code');
+ $code = SignupCode::find($code);
+
+ $step->type('#signup_short_code', $code->short_code)
+ ->click('[type=submit]');
+ });
+
+ // Here we expect text inputs, as well as domain selector, and buttons
+ $browser->whenAvailable('@step3', function ($step) {
+ $step->assertVisible('#signup_login')
+ ->assertVisible('#signup_password')
+ ->assertVisible('#signup_password_confirmation')
+ ->assertVisible('select#signup_domain')
+ ->assertMissing('input#signup_domain')
+ ->assertVisible('#custom_domain')
+ ->assertValue('select#signup_domain option:checked', \config('app.domain'))
+ ->type('#signup_login', 'signuptestdusk')
+ ->type('#signup_password', '12345678')
+ ->type('#signup_password_confirmation', '12345678')
+ ->click('[type=submit]');
+ });
+
+ // At this point we should be auto-logged-in to dashboard
+ $browser->waitUntilMissing('@step3')
+ ->waitUntilMissing('.app-loader')
+ ->on(new Dashboard())
+ ->assertUser('signuptestdusk@' . \config('app.domain'))
+ ->assertVisible('@links a.link-settings')
+ ->assertVisible('@links a.link-domains')
+ ->assertVisible('@links a.link-users')
+ ->assertVisible('@links a.link-wallet')
+ ->assertMissing('@links a.link-distlists')
+ ->assertMissing('@links a.link-shared-folders')
+ ->assertMissing('@links a.link-resources');
+
+ $browser->within(new Menu(), function ($browser) {
+ $browser->clickMenuItem('logout');
+ });
+ });
+ }
+
/**
* Test signup with a mandate plan, also the UI lock
*
@@ -558,6 +639,7 @@
$domains_count = count($domains);
$browser->assertMissing('.card-title')
+ ->waitFor('select#signup_domain option:checked')
->assertElementsCount('select#signup_domain option', $domains_count, false)
->assertText('select#signup_domain option:nth-child(1)', $domains[0])
->assertValue('select#signup_domain option:nth-child(1)', $domains[0])
@@ -585,7 +667,6 @@
})
->on(new PaymentMollie())
->assertSeeIn('@title', 'Auto-Payment Setup')
- ->assertMissing('@amount')
->submitPayment('open')
->on(new PaymentStatus())
->assertSeeIn('@lock-alert', 'The account is locked')
@@ -755,6 +836,7 @@
// Step 2
->whenAvailable('@step3', function ($browser) {
$browser->assertSeeIn('.card-title', 'Sign Up - Step 2/2')
+ ->click('#custom_domain')
->type('input#signup_domain', 'user-domain-signup.com')
->type('#signup_login', 'admin')
->type('#signup_password', '12345678')
@@ -902,11 +984,13 @@
$browser->visit('/signup/invite/' . $invitation->id)
->onWithoutAssert(new Signup())
->waitUntilMissing('.app-loader')
+ ->waitFor('@step0')
+ ->click('.plan-individual button')
->with('@step3', function ($step) {
$domains_count = count(Domain::getPublicDomains());
$step->assertMissing('.card-title')
- ->assertVisible('#signup_last_name')
+ ->waitFor('#signup_last_name')
->assertVisible('#signup_first_name')
->assertVisible('#signup_login')
->assertVisible('#signup_password')
@@ -914,7 +998,7 @@
->assertVisible('select#signup_domain')
->assertElementsCount('select#signup_domain option', $domains_count, false)
->assertVisible('[type=submit]')
- ->assertMissing('[type=button]') // Back button
+ ->assertVisible('[type=button]') // Back button
->assertSeeIn('[type=submit]', 'Sign Up')
->assertFocused('#signup_first_name')
->assertValue('select#signup_domain', \config('app.domain'))
diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -188,14 +188,16 @@
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
- $this->assertCount(1, $json['errors']);
+ $this->assertCount(2, $json['errors']);
$this->assertArrayHasKey('email', $json['errors']);
+ $this->assertArrayHasKey('plan', $json['errors']);
// Data with missing name
$data = [
'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com',
'first_name' => str_repeat('a', 250),
'last_name' => str_repeat('a', 250),
+ 'plan' => 'individual',
];
$response = $this->post('/api/auth/signup/init', $data);
@@ -208,11 +210,12 @@
$this->assertArrayHasKey('first_name', $json['errors']);
$this->assertArrayHasKey('last_name', $json['errors']);
- // Data with invalid email (but not phone number)
+ // Data with invalid email (but not phone number), and invalid plan
$data = [
'email' => '@example.org',
'first_name' => 'Signup',
'last_name' => 'User',
+ 'plan' => 'invalid',
];
$response = $this->post('/api/auth/signup/init', $data);
@@ -221,13 +224,15 @@
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
- $this->assertCount(1, $json['errors']);
+ $this->assertCount(2, $json['errors']);
$this->assertArrayHasKey('email', $json['errors']);
+ $this->assertArrayHasKey('plan', $json['errors']);
// Sanity check on voucher code, last/first name is optional
$data = [
'voucher' => '123456789012345678901234567890123',
'email' => 'valid@email.com',
+ 'plan' => 'individual',
];
$response = $this->post('/api/auth/signup/init', $data);
@@ -244,6 +249,7 @@
'email' => str_repeat('a', 190) . '@example.org',
'first_name' => 'Signup',
'last_name' => 'User',
+ 'plan' => 'individual',
];
$response = $this->post('/api/auth/signup/init', $data);
@@ -262,6 +268,7 @@
'email' => 'test@example.org',
'first_name' => 'Signup',
'last_name' => 'User',
+ 'plan' => 'individual',
];
\config(['app.signup.email_limit' => 0]);
@@ -287,6 +294,7 @@
'email' => 'ip@example.org',
'first_name' => 'Signup',
'last_name' => 'User',
+ 'plan' => 'individual',
];
\config(['app.signup.email_limit' => 0]);
@@ -326,7 +334,7 @@
'email' => 'testuser@external.com',
'first_name' => 'Signup',
'last_name' => 'User',
- 'plan' => 'individual',
+ 'plan' => 'group',
];
$response = $this->post('/api/auth/signup/init', $data, ['REMOTE_ADDR' => '10.1.1.2']);
@@ -334,10 +342,12 @@
$response->assertStatus(200);
- $this->assertCount(3, $json);
+ $this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('email', $json['mode']);
+ $this->assertSame(true, $json['is_domain']);
$this->assertNotEmpty($json['code']);
+ $this->assertSame($all_domains = Domain::getPublicDomains(), $json['domains']);
$code = SignupCode::find($json['code']);
@@ -361,15 +371,18 @@
// Try the same with voucher
$data['voucher'] = 'TEST';
+ $data['plan'] = 'individual';
$response = $this->post('/api/auth/signup/init', $data);
$json = $response->json();
$response->assertStatus(200);
- $this->assertCount(3, $json);
+ $this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('email', $json['mode']);
+ $this->assertSame(false, $json['is_domain']);
$this->assertNotEmpty($json['code']);
+ $this->assertSame($all_domains, $json['domains']);
// Assert the job has proper data assigned
Queue::assertPushed(\App\Jobs\Mail\SignupVerificationJob::class, function ($job) use ($data, $json) {
@@ -461,14 +474,14 @@
$json = $response->json();
$response->assertStatus(200);
- $this->assertCount(7, $json);
+ $this->assertCount(6, $json);
$this->assertSame('success', $json['status']);
$this->assertSame($result['email'], $json['email']);
$this->assertSame($result['first_name'], $json['first_name']);
$this->assertSame($result['last_name'], $json['last_name']);
$this->assertSame($result['voucher'], $json['voucher']);
$this->assertSame(false, $json['is_domain']);
- $this->assertTrue(is_array($json['domains']) && !empty($json['domains']));
+ //$this->assertTrue(is_array($json['domains']) && !empty($json['domains']));
$code->refresh();
@@ -494,10 +507,11 @@
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
- $this->assertCount(3, $json['errors']);
+ $this->assertCount(4, $json['errors']);
$this->assertArrayHasKey('login', $json['errors']);
$this->assertArrayHasKey('password', $json['errors']);
$this->assertArrayHasKey('domain', $json['errors']);
+ $this->assertArrayHasKey('plan', $json['errors']);
$domain = $this->getPublicDomain();
@@ -506,6 +520,7 @@
'login' => 'test',
'password' => 'test',
'password_confirmation' => 'test2',
+ 'plan' => 'individual',
];
$response = $this->post('/api/auth/signup', $data);
@@ -525,6 +540,7 @@
'domain' => $domain,
'password' => 'test',
'password_confirmation' => 'test',
+ 'plan' => 'indvalid',
];
$response = $this->post('/api/auth/signup', $data);
@@ -532,9 +548,10 @@
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
- $this->assertCount(2, $json['errors']);
+ $this->assertCount(3, $json['errors']);
$this->assertArrayHasKey('login', $json['errors']);
$this->assertArrayHasKey('password', $json['errors']);
+ $this->assertArrayHasKey('plan', $json['errors']);
// Missing codes
$data = [
@@ -542,6 +559,7 @@
'domain' => $domain,
'password' => 'testtest',
'password_confirmation' => 'testtest',
+ 'plan' => 'individual',
];
$response = $this->post('/api/auth/signup', $data);
@@ -719,10 +737,12 @@
$json = $response->json();
$response->assertStatus(200);
- $this->assertCount(3, $json);
+ $this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('email', $json['mode']);
+ $this->assertSame(true, $json['is_domain']);
$this->assertNotEmpty($json['code']);
+ $this->assertSame(Domain::getPublicDomains(), $json['domains']);
// Assert the email sending job was pushed once
Queue::assertPushed(\App\Jobs\Mail\SignupVerificationJob::class, 1);
@@ -749,14 +769,13 @@
$result = $response->json();
$response->assertStatus(200);
- $this->assertCount(7, $result);
+ $this->assertCount(6, $result);
$this->assertSame('success', $result['status']);
$this->assertSame($user_data['email'], $result['email']);
$this->assertSame($user_data['first_name'], $result['first_name']);
$this->assertSame($user_data['last_name'], $result['last_name']);
$this->assertSame(null, $result['voucher']);
$this->assertSame(true, $result['is_domain']);
- $this->assertSame([], $result['domains']);
// Final signup request
$login = 'admin';
@@ -871,7 +890,7 @@
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
- $this->assertSame("The selected plan is invalid.", $json['errors']['plan']);
+ $this->assertSame(["The plan field is required."], $json['errors']['plan']);
// Test valid input
$post['plan'] = $plan->title;
@@ -907,14 +926,21 @@
'domain' => 'kolabnow.com',
'password' => 'testtest',
'password_confirmation' => 'testtest',
+ 'plan' => 'individual',
];
// Test invalid invitation identifier
$response = $this->post('/api/auth/signup', $post);
$response->assertStatus(404);
- // Test valid input
+ // Test invalid plan
$post['invitation'] = $invitation->id;
+ $post['plan'] = 'invalid';
+ $response = $this->post('/api/auth/signup', $post);
+ $response->assertStatus(422);
+
+ // Test valid input
+ $post['plan'] = 'individual';
$response = $this->post('/api/auth/signup', $post);
$result = $response->json();
@@ -1034,10 +1060,13 @@
$json = $response->json();
$this->assertSame('error', $json['status']);
- $this->assertSame(['referral' => ["The referral program code is invalid."]], $json['errors']);
+ $this->assertCount(2, $json['errors']);
+ $this->assertSame(['The referral program code is invalid.'], $json['errors']['referral']);
+ $this->assertSame(['The plan field is required.'], $json['errors']['plan']);
// Test valid code
$post['referral'] = $referral_code->code;
+ $post['plan'] = 'individual';
$response = $this->post('/api/auth/signup/init', $post);
$json = $response->json();
@@ -1098,11 +1127,12 @@
$json = $response->json();
$this->assertSame('error', $json['status']);
- $this->assertCount(4, $json['errors']);
+ $this->assertCount(5, $json['errors']);
$this->assertSame(["The login must be at least 2 characters."], $json['errors']['login']);
$this->assertSame(["The password confirmation does not match."], $json['errors']['password']);
$this->assertSame(["The domain field is required."], $json['errors']['domain']);
$this->assertSame(["The voucher may not be greater than 32 characters."], $json['errors']['voucher']);
+ $this->assertSame(["The plan field is required."], $json['errors']['plan']);
// Test with mode=mandate plan, but invalid voucher code
$post = [
@@ -1182,6 +1212,7 @@
['test.test', $domain, false, null],
['test_test', $domain, false, null],
['test-test', $domain, false, null],
+ ['test-test', 'kolab.org', false, ['domain' => 'The specified domain is not available.']],
['admin', $domain, false, ['login' => 'The specified login is not available.']],
['administrator', $domain, false, ['login' => 'The specified login is not available.']],
['sales', $domain, false, ['login' => 'The specified login is not available.']],
@@ -1189,6 +1220,7 @@
// Domain account
['admin', 'kolabsys.com', true, null],
+ ['testsystemdomain', $domain, true, null],
['testnonsystemdomain', 'invalid', true, ['domain' => 'The specified domain is invalid.']],
['testnonsystemdomain', '.com', true, ['domain' => 'The specified domain is invalid.']],
];
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -674,6 +674,17 @@
$result = UsersController::statusInfo($user);
$this->assertTrue($result['enableBeta']);
$this->assertFalse($result['enableRooms']);
+
+ // Test user in a group account without a custom domain
+ $user = $this->getTestUser('UsersControllerTest2@userscontroller.com');
+ $plan = Plan::withObjectTenantContext($user)->where('title', 'group')->first();
+ $user->setSetting('plan_id', $plan->id);
+
+ $result = UsersController::statusInfo($user);
+ $this->assertTrue($result['enableDomains']);
+ $this->assertFalse($result['enableFolders']);
+ $this->assertFalse($result['enableDistlists']);
+ $this->assertFalse($result['enableResources']);
}
/**
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -7,6 +7,7 @@
use App\Group;
use App\Package;
use App\PackageSku;
+use App\Plan;
use App\Sku;
use App\User;
use App\Auth\Utils as AuthUtils;
@@ -115,7 +116,37 @@
*/
public function testAssignPlan(): void
{
- $this->markTestIncomplete();
+ $domain = $this->getTestDomain('useraccount.com', [
+ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE,
+ 'type' => Domain::TYPE_EXTERNAL,
+ ]);
+ $user = $this->getTestUser('useraccounta@' . $domain->namespace);
+ $plan = Plan::withObjectTenantContext($user)->where('title', 'group')->first();
+
+ // Group plan without domain
+ $user->assignPlan($plan);
+
+ $this->assertSame((string) $plan->id, $user->getSetting('plan_id'));
+ $this->assertSame(7, $user->entitlements()->count()); // 5 storage + 1 mailbox + 1 groupware
+
+ $user = $this->getTestUser('useraccountb@' . $domain->namespace);
+
+ // Group plan with a domain
+ $user->assignPlan($plan, $domain);
+
+ $this->assertSame((string) $plan->id, $user->getSetting('plan_id'));
+ $this->assertSame(7, $user->entitlements()->count()); // 5 storage + 1 mailbox + 1 groupware
+ $this->assertSame(1, $domain->entitlements()->count());
+
+ // Individual plan (domain is not allowed)
+ $user = $this->getTestUser('user-test@' . \config('app.domain'));
+ $plan = Plan::withObjectTenantContext($user)->where('title', 'individual')->first();
+
+ $this->expectException(\Exception::class);
+ $user->assignPlan($plan, $domain);
+
+ $this->assertNull($user->getSetting('plan_id'));
+ $this->assertSame(0, $user->entitlements()->count());
}
/**
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 10:38 AM (20 h, 18 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18795918
Default Alt Text
D5122.1775299130.diff (56 KB)
Attached To
Mode
D5122: Signup: Hide plan selection step if there's only one plan
Attached
Detach File
Event Timeline