Page MenuHomePhorge

No OneTemporary

Size
70 KB
Referenced Files
None
Subscribers
None
diff --git a/src/resources/themes/default/_variables.scss b/src/resources/themes/default/_variables.scss
index 74d0ae15..fd9258d3 100644
--- a/src/resources/themes/default/_variables.scss
+++ b/src/resources/themes/default/_variables.scss
@@ -1,17 +1,20 @@
// Body
$body-bg: #fff;
// Typography
$font-family-sans-serif: 'Nunito', sans-serif;
$font-size-base: 0.9rem;
$line-height-base: 1.5;
// Colors
$orange: #f1a539;
$light: #f6f5f3;
// App colors
$menu-bg-color: $light;
$menu-gray: #575656;
$main-color: $orange;
$warning: $orange;
+
+$table-hover-bg: $menu-bg-color;
+$table-head-bg: $menu-bg-color;
\ No newline at end of file
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue
index ab880d27..92172d10 100644
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -1,373 +1,426 @@
<template>
<div class="container">
<status-component v-if="user_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="user-info">
<div class="card-body">
- <div class="card-title" v-if="user_id !== 'new'">User account</div>
+ <div class="card-title" v-if="user_id !== 'new'">User account
+ <button
+ class="btn btn-outline-danger button-delete float-right"
+ v-on:click="showDeleteConfirmation()" tag="button"
+ >
+ <svg-icon icon="trash-alt"></svg-icon> Delete user
+ </button>
+ </div>
<div class="card-title" v-if="user_id === 'new'">New user account</div>
<div class="card-text">
<form @submit.prevent="submit">
<div v-if="user_id !== 'new'" class="form-group row plaintext">
<label for="first_name" class="col-sm-4 col-form-label">Status</label>
<div class="col-sm-8">
<span :class="$root.userStatusClass(user) + ' form-control-plaintext'" id="status">{{ $root.userStatusText(user) }}</span>
</div>
</div>
<div class="form-group row">
<label for="first_name" class="col-sm-4 col-form-label">First name</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="first_name" v-model="user.first_name">
</div>
</div>
<div class="form-group row">
<label for="last_name" class="col-sm-4 col-form-label">Last name</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="last_name" v-model="user.last_name">
</div>
</div>
<div class="form-group row">
<label for="organization" class="col-sm-4 col-form-label">Organization</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="organization" v-model="user.organization">
</div>
</div>
<div class="form-group row">
<label for="email" class="col-sm-4 col-form-label">Email</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="email" :disabled="user_id !== 'new'" required v-model="user.email">
</div>
</div>
<div class="form-group row">
<label for="aliases-input" class="col-sm-4 col-form-label">Email aliases</label>
<div class="col-sm-8">
<list-input id="aliases" :list="user.aliases"></list-input>
</div>
</div>
<div class="form-group row">
<label for="password" class="col-sm-4 col-form-label">Password</label>
<div class="col-sm-8">
<input type="password" class="form-control" id="password" v-model="user.password" :required="user_id === 'new'">
</div>
</div>
<div class="form-group row">
<label for="password_confirmaton" class="col-sm-4 col-form-label">Confirm password</label>
<div class="col-sm-8">
<input type="password" class="form-control" id="password_confirmation" v-model="user.password_confirmation" :required="user_id === 'new'">
</div>
</div>
<div v-if="user_id === 'new'" id="user-packages" class="form-group row">
<label class="col-sm-4 col-form-label">Package</label>
<div class="col-sm-8">
<table class="table table-sm form-list">
<thead class="thead-light sr-only">
<tr>
<th scope="col"></th>
<th scope="col">Package</th>
<th scope="col">Price</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="pkg in packages" :id="'p' + pkg.id" :key="pkg.id">
<td class="selection">
<input type="checkbox" @click="selectPackage"
:value="pkg.id"
:checked="pkg.id == package_id"
:id="'pkg-input-' + pkg.id"
>
</td>
<td class="name">
<label :for="'pkg-input-' + pkg.id">{{ pkg.name }}</label>
</td>
<td class="price text-nowrap">
{{ $root.priceLabel(pkg.cost, 1, discount) }}
</td>
<td class="buttons">
<button v-if="pkg.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="pkg.description">
<svg-icon icon="info-circle"></svg-icon>
<span class="sr-only">More information</span>
</button>
</td>
</tr>
</tbody>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0">
&sup1; applied discount: {{ discount }}% - {{ discount_description }}
</small>
</div>
</div>
<div v-if="user_id !== 'new'" id="user-skus" class="form-group row">
<label class="col-sm-4 col-form-label">Subscriptions</label>
<div class="col-sm-8">
<table class="table table-sm form-list">
<thead class="thead-light sr-only">
<tr>
<th scope="col"></th>
<th scope="col">Subscription</th>
<th scope="col">Price</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="sku in skus" :id="'s' + sku.id" :key="sku.id">
<td class="selection">
<input type="checkbox" @input="onInputSku"
:value="sku.id"
:disabled="sku.readonly"
:checked="sku.enabled"
:id="'sku-input-' + sku.title"
>
</td>
<td class="name">
<label :for="'sku-input-' + sku.title">{{ sku.name }}</label>
<div v-if="sku.range" class="range-input">
<label class="text-nowrap">{{ sku.range.min }} {{ sku.range.unit }}</label>
<input
type="range" class="custom-range" @input="rangeUpdate"
:value="sku.value || sku.range.min"
:min="sku.range.min"
:max="sku.range.max"
>
</div>
</td>
<td class="price text-nowrap">
{{ $root.priceLabel(sku.cost, 1, discount) }}
</td>
<td class="buttons">
<button v-if="sku.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="sku.description">
<svg-icon icon="info-circle"></svg-icon>
<span class="sr-only">More information</span>
</button>
</td>
</tr>
</tbody>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0">
&sup1; applied discount: {{ discount }}% - {{ discount_description }}
</small>
</div>
</div>
<button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
</form>
</div>
</div>
</div>
+ <div id="delete-warning" class="modal" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title"></h5>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <p>Do you really want to delete this user permanently?
+ This will delete all account data and withdraw the permission to access the email account.
+ Please note that this action cannot be undone.</p>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-danger modal-action" @click="deleteUser()">
+ <svg-icon icon="trash-alt"></svg-icon> Delete
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
</div>
</template>
<script>
import ListInput from '../Widgets/ListInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
ListInput,
StatusComponent
},
data() {
return {
discount: 0,
discount_description: '',
user_id: null,
user: { aliases: [] },
packages: [],
package_id: null,
skus: [],
status: {}
}
},
created() {
this.user_id = this.$route.params.user
let wallet = this.$store.state.authInfo.accounts[0]
if (!wallet) {
wallet = this.$store.state.authInfo.wallets[0]
}
if (wallet && wallet.discount) {
this.discount = wallet.discount
this.discount_description = wallet.discount_description
}
this.$root.startLoading()
if (this.user_id === 'new') {
// do nothing (for now)
axios.get('/api/v4/packages')
.then(response => {
this.$root.stopLoading()
this.packages = response.data.filter(pkg => !pkg.isDomain)
this.package_id = this.packages[0].id
})
.catch(this.$root.errorHandler)
}
else {
axios.get('/api/v4/users/' + this.user_id)
.then(response => {
this.$root.stopLoading()
this.user = response.data
this.user.first_name = response.data.settings.first_name
this.user.last_name = response.data.settings.last_name
this.user.organization = response.data.settings.organization
this.discount = this.user.wallet.discount
this.discount_description = this.user.wallet.discount_description
this.status = response.data.statusInfo
axios.get('/api/v4/users/' + this.user_id + '/skus?type=user')
.then(response => {
// "merge" SKUs with user entitlement-SKUs
this.skus = response.data
.map(sku => {
if (sku.id in this.user.skus) {
sku.enabled = true
sku.value = this.user.skus[sku.id].count
} else if (!sku.readonly) {
sku.enabled = false
}
return sku
})
// Update all range inputs (and price)
this.$nextTick(() => {
$('#user-skus input[type=range]').each((idx, elem) => { this.rangeUpdate(elem) })
})
})
.catch(this.$root.errorHandler)
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#first_name').focus()
},
methods: {
submit() {
this.$root.clearFormValidation($('#user-info form'))
let method = 'post'
let location = '/api/v4/users'
if (this.user_id !== 'new') {
method = 'put'
location += '/' + this.user_id
let skus = {}
$('#user-skus input[type=checkbox]:checked').each((idx, input) => {
let id = $(input).val()
let range = $(input).parents('tr').first().find('input[type=range]').val()
skus[id] = range || 1
})
this.user.skus = skus
} else {
this.user.package = this.package_id
}
axios[method](location, this.user)
.then(response => {
if (response.data.statusInfo) {
this.$store.state.authInfo.statusInfo = response.data.statusInfo
}
this.$toast.success(response.data.message)
this.$router.push({ name: 'users' })
})
},
onInputSku(e) {
let input = e.target
let sku = this.findSku(input.value)
let required = []
// We use 'readonly', not 'disabled', because we might want to handle
// input events. For example to display an error when someone clicks
// the locked input
if (input.readOnly) {
input.checked = !input.checked
// TODO: Display an alert explaining why it's locked
return
}
// TODO: Following code might not work if we change definition of forbidden/required
// or we just need more sophisticated SKU dependency rules
if (input.checked) {
// Check if a required SKU is selected, alert the user if not
(sku.required || []).forEach(title => {
this.skus.forEach(item => {
let checkbox
if (item.handler == title && (checkbox = $('#s' + item.id).find('input[type=checkbox]')[0])) {
if (!checkbox.checked) {
required.push(item.name)
}
}
})
})
if (required.length) {
input.checked = false
return alert(sku.name + ' requires ' + required.join(', ') + '.')
}
} else {
// Uncheck all dependent SKUs, e.g. when unchecking Groupware we also uncheck Activesync
// TODO: Should we display an alert instead?
this.skus.forEach(item => {
if (item.required && item.required.indexOf(sku.handler) > -1) {
$('#s' + item.id).find('input[type=checkbox]').prop('checked', false)
}
})
}
// Uncheck+lock/unlock conflicting SKUs
(sku.forbidden || []).forEach(title => {
this.skus.forEach(item => {
let checkbox
if (item.handler == title && (checkbox = $('#s' + item.id).find('input[type=checkbox]')[0])) {
if (input.checked) {
checkbox.checked = false
checkbox.readOnly = true
} else {
checkbox.readOnly = false
}
}
})
})
},
selectPackage(e) {
// Make sure there always is only one package selected
$('#user-packages input').prop('checked', false)
this.package_id = $(e.target).prop('checked', false).val()
},
rangeUpdate(e) {
let input = $(e.target || e)
let value = input.val()
let record = input.parents('tr').first()
let sku_id = record.find('input[type=checkbox]').val()
let sku = this.findSku(sku_id)
let cost = sku.cost
// Update the label
input.prev().text(value + ' ' + sku.range.unit)
// Update the price
record.find('.price').text(this.$root.priceLabel(cost, value - sku.units_free, this.discount))
},
findSku(id) {
for (let i = 0; i < this.skus.length; i++) {
if (this.skus[i].id == id) {
return this.skus[i];
}
}
},
statusUpdate(user) {
this.user = Object.assign({}, this.user, user)
+ },
+ deleteUser() {
+ // Delete the user from the confirm dialog
+ axios.delete('/api/v4/users/' + this.user_id)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ this.$router.push({ name: 'users' })
+ }
+ })
+ },
+ showDeleteConfirmation() {
+ // Deleting self, redirect to /profile/delete page
+ if (this.user_id == this.$store.state.authInfo.id) {
+ this.$router.push({ name: 'profile-delete' })
+ } else {
+ // Display the warning
+ let dialog = $('#delete-warning')
+ dialog.find('.modal-title').text('Delete ' + this.user.email)
+ dialog.on('shown.bs.modal', () => {
+ dialog.find('button.modal-cancel').focus()
+ }).modal()
+ }
}
}
}
</script>
diff --git a/src/resources/vue/User/List.vue b/src/resources/vue/User/List.vue
index ed26eb94..9e77c140 100644
--- a/src/resources/vue/User/List.vue
+++ b/src/resources/vue/User/List.vue
@@ -1,131 +1,57 @@
<template>
<div class="container">
<div class="card" id="user-list">
<div class="card-body">
<div class="card-title">
User Accounts
- <router-link class="btn btn-primary float-right create-user" :to="{ path: 'user/new' }" tag="button">
+ <router-link class="btn btn-success float-right create-user" :to="{ path: 'user/new' }" tag="button">
<svg-icon icon="user"></svg-icon> Create user
</router-link>
</div>
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
<th scope="col">Primary Email</th>
- <th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :id="'user' + user.id" :key="user.id" @click="$root.clickRecord">
<td>
<svg-icon icon="user" :class="$root.userStatusClass(user)" :title="$root.userStatusText(user)"></svg-icon>
<router-link :to="{ path: 'user/' + user.id }">{{ user.email }}</router-link>
</td>
- <td class="buttons">
- <button v-if="$root.isController(user.wallet_id)"
- title="Delete"
- class="btn btn-link text-danger button-delete p-0"
- @click="deleteUser(user.id)"
- >
- <svg-icon icon="trash-alt"></svg-icon><span class="sr-only">Delete</span>
- </button>
- </td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td colspan="2">There are no users in this account.</td>
+ <td>There are no users in this account.</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
-
- <div id="delete-warning" class="modal" tabindex="-1" role="dialog">
- <div class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title"></h5>
- <button type="button" class="close" data-dismiss="modal" aria-label="Close">
- <span aria-hidden="true">&times;</span>
- </button>
- </div>
- <div class="modal-body">
- <p>Do you really want to delete this user permanently?
- This will delete all account data and withdraw the permission to access the email account.
- Please note that this action cannot be undone.</p>
- </div>
- <div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
- <button type="button" class="btn btn-danger modal-action" @click="deleteUser()">
- <svg-icon icon="trash-alt"></svg-icon> Delete
- </button>
- </div>
- </div>
- </div>
- </div>
</div>
</template>
<script>
export default {
data() {
return {
users: [],
current_user: null
}
},
created() {
this.$root.startLoading()
axios.get('/api/v4/users')
.then(response => {
this.$root.stopLoading()
this.users = response.data
})
.catch(this.$root.errorHandler)
- },
- methods: {
- deleteUser(id) {
- let dialog = $('#delete-warning').modal('hide')
-
- // Delete the user from the confirm dialog
- if (!id && this.current_user) {
- id = this.current_user.id
- axios.delete('/api/v4/users/' + id)
- .then(response => {
- if (response.data.status == 'success') {
- this.$toast.success(response.data.message)
- $('#user' + id).remove()
- }
- })
-
- return
- }
-
- // Deleting self, redirect to /profile/delete page
- if (id == this.$store.state.authInfo.id) {
- this.$router.push({ name: 'profile-delete' })
- return
- }
-
- // Display the warning
- if (this.current_user = this.getUser(id)) {
- dialog.find('.modal-title').text('Delete ' + this.current_user.email)
- dialog.on('shown.bs.modal', () => {
- dialog.find('button.modal-cancel').focus()
- }).modal()
- }
- },
- getUser(id) {
- for (let i = 0; i < this.users.length; i++) {
- if (this.users[i].id == id) {
- return this.users[i]
- }
- }
- }
}
}
</script>
diff --git a/src/resources/vue/User/Profile.vue b/src/resources/vue/User/Profile.vue
index fe38f355..179b37c5 100644
--- a/src/resources/vue/User/Profile.vue
+++ b/src/resources/vue/User/Profile.vue
@@ -1,117 +1,121 @@
<template>
<div class="container">
<div class="card" id="user-profile">
<div class="card-body">
- <div class="card-title">Your profile</div>
+ <div class="card-title">
+ Your profile
+ <router-link
+ v-if="$root.isController(wallet_id)"
+ class="btn btn-outline-danger button-delete float-right"
+ to="/profile/delete" tag="button"
+ >
+ <svg-icon icon="trash-alt"></svg-icon> Delete account
+ </router-link>
+ </div>
<div class="card-text">
<form @submit.prevent="submit">
<div class="form-group row plaintext">
<label class="col-sm-4 col-form-label">Customer No.</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="userid">{{ user_id }}</span>
</div>
</div>
<div class="form-group row">
<label for="first_name" class="col-sm-4 col-form-label">First name</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="first_name" v-model="profile.first_name">
</div>
</div>
<div class="form-group row">
<label for="last_name" class="col-sm-4 col-form-label">Last name</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="last_name" v-model="profile.last_name">
</div>
</div>
<div class="form-group row">
<label for="organization" class="col-sm-4 col-form-label">Organization</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="organization" v-model="profile.organization">
</div>
</div>
<div class="form-group row">
<label for="phone" class="col-sm-4 col-form-label">Phone</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="phone" v-model="profile.phone">
</div>
</div>
<div class="form-group row">
<label for="external_email" class="col-sm-4 col-form-label">External email</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="external_email" v-model="profile.external_email">
</div>
</div>
<div class="form-group row">
<label for="billing_address" class="col-sm-4 col-form-label">Address</label>
<div class="col-sm-8">
<textarea class="form-control" id="billing_address" rows="3" v-model="profile.billing_address"></textarea>
</div>
</div>
<div class="form-group row">
<label for="country" class="col-sm-4 col-form-label">Country</label>
<div class="col-sm-8">
<select class="form-control custom-select" id="country" v-model="profile.country">
<option value="">-</option>
<option v-for="(item, code) in countries" :value="code" :key="code">{{ item[1] }}</option>
</select>
</div>
</div>
<div class="form-group row">
<label for="password" class="col-sm-4 col-form-label">Password</label>
<div class="col-sm-8">
<input type="password" class="form-control" id="password" v-model="profile.password">
</div>
</div>
<div class="form-group row">
<label for="password_confirmaton" class="col-sm-4 col-form-label">Confirm password</label>
<div class="col-sm-8">
<input type="password" class="form-control" id="password_confirmation" v-model="profile.password_confirmation">
</div>
</div>
<button class="btn btn-primary button-submit" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
- <router-link
- v-if="$root.isController(wallet_id)"
- class="btn btn-danger button-delete"
- to="/profile/delete" tag="button"
- ><svg-icon icon="trash-alt"></svg-icon> Delete account</router-link>
</form>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
profile: {},
user_id: null,
wallet_id: null,
countries: window.config.countries
}
},
created() {
this.wallet_id = this.$store.state.authInfo.wallet.id
this.profile = this.$store.state.authInfo.settings
this.user_id = this.$store.state.authInfo.id
},
mounted() {
$('#first_name').focus()
},
methods: {
submit() {
this.$root.clearFormValidation($('#user-profile form'))
axios.put('/api/v4/users/' + this.user_id, this.profile)
.then(response => {
delete this.profile.password
delete this.profile.password_confirm
this.$toast.success(response.data.message)
this.$router.push({ name: 'dashboard' })
})
}
}
}
</script>
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
index 1d732422..f6a2250a 100644
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -1,672 +1,658 @@
<?php
namespace Tests\Browser;
use App\Discount;
use App\Entitlement;
use App\Sku;
use App\User;
use App\UserAlias;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\ListInput;
use Tests\Browser\Components\QuotaInput;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\UserInfo;
use Tests\Browser\Pages\UserList;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UsersTest extends TestCaseDusk
{
private $profile = [
'first_name' => 'John',
'last_name' => 'Doe',
'organization' => 'Kolab Developers',
];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testInfoUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$user = User::where('email', 'john@kolab.org')->first();
$browser->visit('/user/' . $user->id)->on(new Home());
});
}
/**
* Test users list page (unauthenticated)
*/
public function testListUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/users')->on(new Home());
});
}
/**
* Test users list page
*/
public function testList(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-users', 'User accounts')
->click('@links .link-users')
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->waitFor('tbody tr')
->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org')
- ->assertVisible('tbody tr:nth-child(1) button.button-delete')
- ->assertVisible('tbody tr:nth-child(2) button.button-delete')
- ->assertVisible('tbody tr:nth-child(3) button.button-delete')
- ->assertVisible('tbody tr:nth-child(4) button.button-delete')
->assertMissing('tfoot');
});
});
}
/**
* Test user account editing page (not profile page)
*
* @depends testList
*/
public function testInfo(): void
{
$this->browse(function (Browser $browser) {
$browser->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@form', function (Browser $browser) {
// Assert form content
$browser->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) #status', 'Active')
->assertFocused('div.row:nth-child(2) input')
->assertSeeIn('div.row:nth-child(2) label', 'First name')
->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name'])
->assertSeeIn('div.row:nth-child(3) label', 'Last name')
->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name'])
->assertSeeIn('div.row:nth-child(4) label', 'Organization')
->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization'])
->assertSeeIn('div.row:nth-child(5) label', 'Email')
->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org')
->assertDisabled('div.row:nth-child(5) input[type=text]')
->assertSeeIn('div.row:nth-child(6) label', 'Email aliases')
->assertVisible('div.row:nth-child(6) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['john.doe@kolab.org'])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(7) label', 'Password')
->assertValue('div.row:nth-child(7) input[type=password]', '')
->assertSeeIn('div.row:nth-child(8) label', 'Confirm password')
->assertValue('div.row:nth-child(8) input[type=password]', '')
->assertSeeIn('button[type=submit]', 'Submit')
// Clear some fields and submit
->vueClear('#first_name')
->vueClear('#last_name')
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@form', function (Browser $browser) {
// Test error handling (password)
$browser->type('#password', 'aaaaaa')
->vueClear('#password_confirmation')
->click('button[type=submit]')
->waitFor('#password + .invalid-feedback')
->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.')
->assertFocused('#password')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
// TODO: Test password change
// Test form error handling (aliases)
$browser->vueClear('#password')
->vueClear('#password_confirmation')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(2, 'The specified alias is invalid.', false);
});
// Test adding aliases
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(2)
->addListEntry('john.test@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
$john = User::where('email', 'john@kolab.org')->first();
$alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first();
$this->assertTrue(!empty($alias));
// Test subscriptions
$browser->with('@form', function (Browser $browser) {
$browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions')
->assertVisible('@skus.row:nth-child(9)')
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 6)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox')
->assertSeeIn('tbody tr:nth-child(1) td.price', '4,44 CHF/month')
->assertChecked('tbody tr:nth-child(1) td.selection input')
->assertDisabled('tbody tr:nth-child(1) td.selection input')
->assertTip(
'tbody tr:nth-child(1) td.buttons button',
'Just a mailbox'
)
// Storage SKU
->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota')
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month')
->assertChecked('tbody tr:nth-child(2) td.selection input')
->assertDisabled('tbody tr:nth-child(2) td.selection input')
->assertTip(
'tbody tr:nth-child(2) td.buttons button',
'Some wiggle room'
)
->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) {
$browser->assertQuotaValue(2)->setQuotaValue(3);
})
->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features')
->assertSeeIn('tbody tr:nth-child(3) td.price', '5,55 CHF/month')
->assertChecked('tbody tr:nth-child(3) td.selection input')
->assertEnabled('tbody tr:nth-child(3) td.selection input')
->assertTip(
'tbody tr:nth-child(3) td.buttons button',
'Groupware functions like Calendar, Tasks, Notes, etc.'
)
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync')
->assertSeeIn('tbody tr:nth-child(4) td.price', '1,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(4) td.selection input')
->assertEnabled('tbody tr:nth-child(4) td.selection input')
->assertTip(
'tbody tr:nth-child(4) td.buttons button',
'Mobile synchronization'
)
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication')
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(5) td.selection input')
->assertEnabled('tbody tr:nth-child(5) td.selection input')
->assertTip(
'tbody tr:nth-child(5) td.buttons button',
'Two factor authentication for webmail and administration panel'
)
// Meet SKU
->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(6) td.selection input')
->assertEnabled('tbody tr:nth-child(6) td.selection input')
->assertTip(
'tbody tr:nth-child(6) td.buttons button',
'Video conferencing tool'
)
->click('tbody tr:nth-child(4) td.selection input');
})
->assertMissing('@skus table + .hint')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
$expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage'];
$this->assertUserEntitlements($john, $expected);
// Test subscriptions interaction
$browser->with('@form', function (Browser $browser) {
$browser->with('@skus', function ($browser) {
// Uncheck 'groupware', expect activesync unchecked
$browser->click('#sku-input-groupware')
->assertNotChecked('#sku-input-groupware')
->assertNotChecked('#sku-input-activesync')
->assertEnabled('#sku-input-activesync')
->assertNotReadonly('#sku-input-activesync')
// Check 'activesync', expect an alert
->click('#sku-input-activesync')
->assertDialogOpened('Activesync requires Groupware Features.')
->acceptDialog()
->assertNotChecked('#sku-input-activesync')
// Check 'meet', expect an alert
->click('#sku-input-meet')
->assertDialogOpened('Voice & Video Conferencing (public beta) requires Groupware Features.')
->acceptDialog()
->assertNotChecked('#sku-input-meet')
// Check '2FA', expect 'activesync' unchecked and readonly
->click('#sku-input-2fa')
->assertChecked('#sku-input-2fa')
->assertNotChecked('#sku-input-activesync')
->assertReadonly('#sku-input-activesync')
// Uncheck '2FA'
->click('#sku-input-2fa')
->assertNotChecked('#sku-input-2fa')
->assertNotReadonly('#sku-input-activesync');
});
});
});
}
/**
* Test user adding page
*
* @depends testList
*/
public function testNewUser(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->assertSeeIn('button.create-user', 'Create user')
->click('button.create-user')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'New user account')
->with('@form', function (Browser $browser) {
// Assert form content
$browser->assertFocused('div.row:nth-child(1) input')
->assertSeeIn('div.row:nth-child(1) label', 'First name')
->assertValue('div.row:nth-child(1) input[type=text]', '')
->assertSeeIn('div.row:nth-child(2) label', 'Last name')
->assertValue('div.row:nth-child(2) input[type=text]', '')
->assertSeeIn('div.row:nth-child(3) label', 'Organization')
->assertValue('div.row:nth-child(3) input[type=text]', '')
->assertSeeIn('div.row:nth-child(4) label', 'Email')
->assertValue('div.row:nth-child(4) input[type=text]', '')
->assertEnabled('div.row:nth-child(4) input[type=text]')
->assertSeeIn('div.row:nth-child(5) label', 'Email aliases')
->assertVisible('div.row:nth-child(5) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue([])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(6) label', 'Password')
->assertValue('div.row:nth-child(6) input[type=password]', '')
->assertSeeIn('div.row:nth-child(7) label', 'Confirm password')
->assertValue('div.row:nth-child(7) input[type=password]', '')
->assertSeeIn('div.row:nth-child(8) label', 'Package')
// assert packages list widget, select "Lite Account"
->with('@packages', function ($browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account')
->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account')
->assertSeeIn('tbody tr:nth-child(1) .price', '9,99 CHF/month')
->assertSeeIn('tbody tr:nth-child(2) .price', '4,44 CHF/month')
->assertChecked('tbody tr:nth-child(1) input')
->click('tbody tr:nth-child(2) input')
->assertNotChecked('tbody tr:nth-child(1) input')
->assertChecked('tbody tr:nth-child(2) input');
})
->assertMissing('@packages table + .hint')
->assertSeeIn('button[type=submit]', 'Submit');
// Test browser-side required fields and error handling
$browser->click('button[type=submit]')
->assertFocused('#email')
->type('#email', 'invalid email')
->click('button[type=submit]')
->assertFocused('#password')
->type('#password', 'simple123')
->click('button[type=submit]')
->assertFocused('#password_confirmation')
->type('#password_confirmation', 'simple')
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.')
->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.');
});
// Test form error handling (aliases)
$browser->with('@form', function (Browser $browser) {
$browser->type('#email', 'julia.roberts@kolab.org')
->type('#password_confirmation', 'simple123')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(1, 'The specified alias is invalid.', false);
});
});
// Successful account creation
$browser->with('@form', function (Browser $browser) {
$browser->type('#first_name', 'Julia')
->type('#last_name', 'Roberts')
->type('#organization', 'Test Org')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(1)
->addListEntry('julia.roberts2@kolab.org');
})
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.')
// check redirection to users list
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 5)
->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first();
$this->assertTrue(!empty($alias));
$this->assertUserEntitlements($julia, ['mailbox', 'storage', 'storage']);
$this->assertSame('Julia', $julia->getSetting('first_name'));
$this->assertSame('Roberts', $julia->getSetting('last_name'));
$this->assertSame('Test Org', $julia->getSetting('organization'));
// Some additional tests for the list input widget
$browser->click('tbody tr:nth-child(4) a')
->on(new UserInfo())
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['julia.roberts2@kolab.org'])
->addListEntry('invalid address')
->type('.input-group:nth-child(2) input', '@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertVisible('.input-group:nth-child(2) input.is-invalid')
->assertVisible('.input-group:nth-child(3) input.is-invalid')
->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org')
->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all();
$this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases);
});
}
/**
* Test user delete
*
* @depends testNewUser
*/
public function testDeleteUser(): void
{
// First create a new user
$john = $this->getTestUser('john@kolab.org');
$julia = $this->getTestUser('julia.roberts@kolab.org');
$package_kolab = \App\Package::where('title', 'kolab')->first();
$john->assignPackage($package_kolab, $julia);
// Test deleting non-controller user
- $this->browse(function (Browser $browser) {
- $browser->visit(new UserList())
- ->whenAvailable('@table', function (Browser $browser) {
- $browser->assertElementsCount('tbody tr', 5)
- ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org')
- ->click('tbody tr:nth-child(4) button.button-delete');
- })
+ $this->browse(function (Browser $browser) use ($julia) {
+ $browser->visit('/user/' . $julia->id)
+ ->on(new UserInfo())
+ ->assertSeeIn('button.button-delete', 'Delete user')
+ ->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org')
->assertFocused('@button-cancel')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Delete')
->click('@button-cancel');
})
- ->whenAvailable('@table', function (Browser $browser) {
- $browser->click('tbody tr:nth-child(4) button.button-delete');
- })
+ ->waitUntilMissing('#delete-warning')
+ ->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->click('@button-action');
})
+ ->waitUntilMissing('#delete-warning')
->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.')
+ ->on(new UserList())
->with('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$this->assertTrue(empty($julia));
-
- // Test clicking Delete on the controller record redirects to /profile/delete
- $browser
- ->with('@table', function (Browser $browser) {
- $browser->click('tbody tr:nth-child(3) button.button-delete');
- })
- ->waitForLocation('/profile/delete');
});
// Test that non-controller user cannot see/delete himself on the users list
- // Note: Access to /profile/delete page is tested in UserProfileTest.php
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->visit(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 0)
->assertSeeIn('tfoot td', 'There are no users in this account.');
});
});
- // Test that controller user (Ned) can see/delete all the users ???
+ // Test that controller user (Ned) can see all the users
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('ned@kolab.org', 'simple123', true)
->visit(new UserList())
->whenAvailable('@table', function (Browser $browser) {
- $browser->assertElementsCount('tbody tr', 4)
- ->assertElementsCount('tbody button.button-delete', 4);
+ $browser->assertElementsCount('tbody tr', 4);
});
// TODO: Test the delete action in details
});
// TODO: Test what happens with the logged in user session after he's been deleted by another user
}
/**
* Test discounted sku/package prices in the UI
*/
public function testDiscountedPrices(): void
{
// Add 10% discount
$discount = Discount::where('code', 'TEST')->first();
$john = User::where('email', 'john@kolab.org')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->save();
// SKUs on user edit page
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->visit(new UserList())
->waitFor('@table tr:nth-child(2)')
->click('@table tr:nth-child(2) a')
->on(new UserInfo())
->with('@form', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
->assertElementsCount('tbody tr', 6)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.price', '3,99 CHF/month¹')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(100);
})
->assertSeeIn('tr:nth-child(2) td.price', '21,56 CHF/month¹')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.price', '4,99 CHF/month¹')
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.price', '0,90 CHF/month¹')
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
// Packages on new user page
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->click('button.create-user')
->on(new UserInfo())
->with('@form', function (Browser $browser) {
$browser->whenAvailable('@packages', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1) .price', '8,99 CHF/month¹') // Groupware
->assertSeeIn('tbody tr:nth-child(2) .price', '3,99 CHF/month¹'); // Lite
})
->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
}
/**
* Test beta entitlements
*
* @depends testList
*/
public function testBetaEntitlements(): void
{
$this->browse(function (Browser $browser) {
$john = User::where('email', 'john@kolab.org')->first();
$sku = Sku::where('title', 'beta')->first();
$john->assignSku($sku);
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 7)
// Beta/Meet SKU
->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(6) td.selection input')
->assertEnabled('tbody tr:nth-child(6) td.selection input')
->assertTip(
'tbody tr:nth-child(6) td.buttons button',
'Video conferencing tool'
)
// Beta SKU
->assertSeeIn('tbody tr:nth-child(7) td.name', 'Private Beta (invitation only)')
->assertSeeIn('tbody tr:nth-child(7) td.price', '0,00 CHF/month')
->assertChecked('tbody tr:nth-child(7) td.selection input')
->assertEnabled('tbody tr:nth-child(7) td.selection input')
->assertTip(
'tbody tr:nth-child(7) td.buttons button',
'Access to the private beta program subscriptions'
)
/*
// Check Meet, Uncheck Beta, expect Meet unchecked
->click('#sku-input-meet')
->click('#sku-input-beta')
->assertNotChecked('#sku-input-beta')
->assertNotChecked('#sku-input-meet')
// Click Meet expect an alert
->click('#sku-input-meet')
->assertDialogOpened('Video chat requires Beta program.')
->acceptDialog()
*/
// Enable Meet and submit
->click('#sku-input-meet');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$expected = ['beta', 'groupware', 'mailbox', 'meet', 'storage', 'storage'];
$this->assertUserEntitlements($john, $expected);
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->click('#sku-input-beta')
->click('#sku-input-meet')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$expected = ['groupware', 'mailbox', 'storage', 'storage'];
$this->assertUserEntitlements($john, $expected);
});
}
}

File Metadata

Mime Type
text/x-diff
Expires
Tue, Feb 3, 6:12 AM (1 d, 10 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
427014
Default Alt Text
(70 KB)

Event Timeline