Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117922714
D5122.1775436864.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
28 KB
Referenced Files
None
Subscribers
None
D5122.1775436864.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
@@ -124,15 +124,13 @@
'status' => 'success',
'code' => $code->code,
'mode' => $plan->mode ?: 'email',
+ 'domains' => Domain::getPublicDomains(),
];
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();
+ $response['is_domain'] = $plan->hasDomain();
} else {
// External email verification, send an email message
SignupVerificationJob::dispatch($code);
@@ -156,18 +154,15 @@
return $this->errorResponse(404);
}
- $has_domain = $this->getPlan()->hasDomain();
-
$result = [
'id' => $id,
- 'is_domain' => $has_domain,
- 'domains' => $has_domain ? [] : Domain::getPublicDomains(),
+ 'is_domain' => $this->getPlan()->hasDomain(),
+ 'domains' => Domain::getPublicDomains(),
];
return response()->json($result);
}
-
/**
* Validation of the verification code.
*
@@ -212,8 +207,6 @@
$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 +215,8 @@
'first_name' => $code->first_name,
'last_name' => $code->last_name,
'voucher' => $code->voucher,
- 'is_domain' => $has_domain,
- 'domains' => $has_domain ? [] : Domain::getPublicDomains(),
+ 'is_domain' => $this->getPlan()->hasDomain(),
+ 'domains' => Domain::getPublicDomains(),
]);
}
@@ -391,7 +384,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,
@@ -593,7 +586,13 @@
// ...otherwise use the default plan
if (empty($plan)) {
// TODO: Get default plan title from config
- $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
+ $plan = Plan::withEnvTenantContext()->whereIn('title', ['group', 'individual'])
+ ->orderBy('title')
+ ->first();
+
+ if (empty($plan)) {
+ throw new \Exception("No plan defined for signup");
+ }
}
$request->plan = $plan;
@@ -605,13 +604,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 +622,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;
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/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/vue/Signup.vue b/src/resources/vue/Signup.vue
--- a/src/resources/vue/Signup.vue
+++ b/src/resources/vue/Signup.vue
@@ -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>
@@ -176,7 +180,9 @@
},
plans: [],
referral: '',
+ selectedDomain: '',
token: '',
+ use_custom: false,
voucher: ''
}
},
@@ -247,29 +253,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' ? 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 +288,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
@@ -318,12 +311,7 @@
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.setDomain(response.data)
})
.catch(error => {
if (bylink === true) {
@@ -394,10 +382,19 @@
return this.step0()
}
+ if (step > 2 && !this.domains.length) {
+ axios.get('/api/auth/signup/domains')
+ .then(response => {
+ this.setDomain(response.data)
+ this.displayForm(step, true)
+ })
+ return
+ }
+
$('#step' + step).removeClass('d-none').find('form')[0].reset()
if (focus) {
- $('#step' + step).find('input').first().focus()
+ $('#step' + step).find('input:not([type=checkbox])').first().focus()
}
},
lastStepPostData() {
@@ -430,6 +427,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/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,68 @@
});
}
+ /**
+ * 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');
+
+ $browser->within(new Menu(), function ($browser) {
+ $browser->clickMenuItem('logout');
+ });
+ });
+ }
+
/**
* Test signup with a mandate plan, also the UI lock
*
@@ -558,6 +636,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 +664,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 +833,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')
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
@@ -163,6 +163,8 @@
$json = $response->json();
$this->assertSame($invitation->id, $json['id']);
+ $this->assertTrue($json['is_domain']); // depends on which plan is default
+ $this->assertSame(Domain::getPublicDomains(), $json['domains']);
// Test non-existing invitation
$response = $this->get("/api/auth/signup/invitations/abc");
@@ -334,10 +336,11 @@
$response->assertStatus(200);
- $this->assertCount(3, $json);
+ $this->assertCount(4, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('email', $json['mode']);
$this->assertNotEmpty($json['code']);
+ $this->assertSame($all_domains = Domain::getPublicDomains(), $json['domains']);
$code = SignupCode::find($json['code']);
@@ -366,10 +369,11 @@
$json = $response->json();
$response->assertStatus(200);
- $this->assertCount(3, $json);
+ $this->assertCount(4, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('email', $json['mode']);
$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) {
@@ -719,10 +723,11 @@
$json = $response->json();
$response->assertStatus(200);
- $this->assertCount(3, $json);
+ $this->assertCount(4, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('email', $json['mode']);
$this->assertNotEmpty($json['code']);
+ $this->assertSame($all_domains = Domain::getPublicDomains(), $json['domains']);
// Assert the email sending job was pushed once
Queue::assertPushed(\App\Jobs\Mail\SignupVerificationJob::class, 1);
@@ -756,7 +761,7 @@
$this->assertSame($user_data['last_name'], $result['last_name']);
$this->assertSame(null, $result['voucher']);
$this->assertSame(true, $result['is_domain']);
- $this->assertSame([], $result['domains']);
+ $this->assertSame($all_domains, $json['domains']);
// Final signup request
$login = 'admin';
@@ -1182,6 +1187,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 +1195,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/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
Mon, Apr 6, 12:54 AM (8 h, 29 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18833290
Default Alt Text
D5122.1775436864.diff (28 KB)
Attached To
Mode
D5122: Signup: Hide plan selection step if there's only one plan
Attached
Detach File
Event Timeline