Page MenuHomePhorge

D5793.1775234953.diff
No OneTemporary

Authored By
Unknown
Size
19 KB
Referenced Files
None
Subscribers
None

D5793.1775234953.diff

diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
--- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
@@ -7,6 +7,7 @@
use App\EventLog;
use App\Group;
use App\Http\Resources\UserResource;
+use App\Http\Resources\UserSummaryResource;
use App\Payment;
use App\Resource;
use App\SharedFolder;
@@ -300,6 +301,27 @@
return $this->errorResponse(404);
}
+ /**
+ * Get summary of a user (could be a soft-deleted one).
+ *
+ * @param Request $request the API request
+ * @param string $id User identifier
+ */
+ public function summary(Request $request, $id): JsonResponse
+ {
+ $user = User::withTrashed()->find($id);
+
+ if (!$this->checkTenant($user)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canUpdate($user)) {
+ return $this->errorResponse(403);
+ }
+
+ return (new UserSummaryResource($user))->response();
+ }
+
/**
* Suspend the user
*
diff --git a/src/app/Http/Resources/UserSummaryResource.php b/src/app/Http/Resources/UserSummaryResource.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Resources/UserSummaryResource.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Http\Resources;
+
+use App\Providers\PaymentProvider;
+use Illuminate\Http\Request;
+
+/**
+ * User summary response
+ */
+class UserSummaryResource extends UserResource
+{
+ /** @var array List of user setting keys in a response */
+ public const USER_SETTINGS = [
+ 'billing_address',
+ 'country',
+ 'external_email',
+ 'first_name',
+ 'last_name',
+ 'organization',
+ 'phone',
+ ];
+
+ /**
+ * Transform the resource into an array.
+ */
+ public function toArray(Request $request): array
+ {
+ $wallet = $this->resource->wallet();
+ $isOwner = $wallet->user_id == $this->resource->id;
+ $isAdmin = self::isAdmin();
+
+ // Payment provider info
+ if ($isOwner && $isAdmin) {
+ $provider = PaymentProvider::factory($wallet);
+ $providerName = $provider->name();
+ $providerLink = $provider->customerLink($wallet);
+ }
+
+ // User settings
+ $settings = $this->resource->settings()->whereIn('key', self::USER_SETTINGS)->pluck('value', 'key')->all();
+
+ return [
+ $this->merge(parent::toArray($request)),
+
+ // @var array<strig, mixed> User settings (first_name, last_name, phone, etc.)
+ 'settings' => $settings,
+
+ // Payment provider name
+ 'provider' => $this->when($isOwner && $isAdmin, $providerName ?? null),
+
+ // Link to the customer page at the payment provider site
+ 'providerLink' => $this->when($isOwner && $isAdmin, $providerLink ?? null),
+
+ // Wallet the user is in
+ 'wallet' => new WalletResource($wallet),
+ ];
+ }
+}
diff --git a/src/app/Http/Resources/WalletResource.php b/src/app/Http/Resources/WalletResource.php
--- a/src/app/Http/Resources/WalletResource.php
+++ b/src/app/Http/Resources/WalletResource.php
@@ -39,7 +39,7 @@
// Wallet owner (user identifier)
'user_id' => $this->resource->user_id,
// Wallet owner (email address)
- 'user_email' => $this->when(self::isAdmin(), $this->resource?->owner->email),
+ 'user_email' => $this->when(self::isAdmin(), $this->resource->owner()->withTrashed()->first()?->email),
// Payment provider name
'provider' => $provider->name(),
// Wallet discount identifier (if any)
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
@@ -583,6 +583,7 @@
'search' => "User email address or name",
'search-pl' => "User ID, email or domain",
'skureq' => "{sku} requires {list}.",
+ 'summary' => "User summary",
'subscription' => "Subscription",
'subscriptions-none' => "This user has no subscriptions.",
'users' => "Users",
@@ -602,6 +603,7 @@
'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.",
'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.",
'auto-payment-update' => "Update auto-payment",
+ 'balance' => "Wallet balance",
'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.",
'coinbase-hint' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}."
. " We will then create a charge on Coinbase for the specified amount that you can pay using Bitcoin.",
diff --git a/src/resources/vue/Widgets/UserSearch.vue b/src/resources/vue/Widgets/UserSearch.vue
--- a/src/resources/vue/Widgets/UserSearch.vue
+++ b/src/resources/vue/Widgets/UserSearch.vue
@@ -17,30 +17,119 @@
</tr>
</thead>
<tbody>
+ <!-- eslint-disable vue/no-template-shadow -->
<tr v-for="user in users" :id="'user' + user.id" :key="user.id" :class="user.isDeleted ? 'text-secondary' : ''">
<td class="text-nowrap">
<svg-icon icon="user" :class="'me-1 ' + $root.statusClass(user)" :title="$root.statusText(user)"></svg-icon>
<router-link v-if="!user.isDeleted" :to="{ path: 'user/' + user.id }">{{ user.email }}</router-link>
- <span v-if="user.isDeleted">{{ user.email }}</span>
+ <a v-if="user.isDeleted" href="#" @click="summaryDialog(user.id)">{{ user.email }}</a>
</td>
<td>
<router-link v-if="!user.isDeleted" :to="{ path: 'user/' + user.id }">{{ user.id }}</router-link>
- <span v-if="user.isDeleted">{{ user.id }}</span>
+ <a v-if="user.isDeleted" href="#" @click="summaryDialog(user.id)">{{ user.id }}</a>
</td>
<td class="d-none d-md-table-cell">{{ toDate(user.created_at) }}</td>
<td class="d-none d-md-table-cell">{{ toDate(user.deleted_at) }}</td>
</tr>
+ <!-- eslint-enable -->
</tbody>
</table>
</div>
+
+ <modal-dialog id="summary-dialog" ref="summaryDialog" :title="$t('user.summary')">
+ <form class="read-only short" style="min-height: 5em">
+ <div class="row plaintext" v-if="user.email">
+ <label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label>
+ <div class="col-sm-8">
+ <strong class="form-control-plaintext" id="email">{{ user.email }}</strong>
+ </div>
+ </div>
+ <div class="row plaintext" v-if="user.status">
+ <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="status">
+ <span :class="$root.statusClass(user)">{{ $root.statusText(user) }}</span>
+ <span v-if="user.isRestricted" class="badge bg-primary rounded-pill ms-1">{{ $t('status.restricted') }}</span>
+ </span>
+ </div>
+ </div>
+ <div class="row plaintext" v-if="user.first_name">
+ <label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="first_name">{{ user.first_name }}</span>
+ </div>
+ </div>
+ <div class="row plaintext" v-if="user.last_name">
+ <label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="last_name">{{ user.last_name }}</span>
+ </div>
+ </div>
+ <div class="row plaintext" v-if="user.organization">
+ <label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="organization">{{ user.organization }}</span>
+ </div>
+ </div>
+ <div class="row plaintext" v-if="user.phone">
+ <label for="phone" class="col-sm-4 col-form-label">{{ $t('form.phone') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="phone">{{ user.phone }}</span>
+ </div>
+ </div>
+ <div class="row plaintext" v-if="user.external_email">
+ <label for="external_email" class="col-sm-4 col-form-label">{{ $t('user.ext-email') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="external_email">
+ <a v-if="user.external_email" :href="'mailto:' + user.external_email">{{ user.external_email }}</a>
+ </span>
+ </div>
+ </div>
+ <div class="row plaintext" v-if="user.billing_address">
+ <label for="billing_address" class="col-sm-4 col-form-label">{{ $t('user.address') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" style="white-space:pre" id="billing_address">{{ user.billing_address }}</span>
+ </div>
+ </div>
+ <div class="row plaintext" v-if="user.country">
+ <label for="country" class="col-sm-4 col-form-label">{{ $t('user.country') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="country">{{ user.country }}</span>
+ </div>
+ </div>
+ <div class="row" v-if="user.providerLink">
+ <label class="col-sm-4 col-form-label">{{ capitalize(user.provider) }} {{ $t('form.id') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" v-html="user.providerLink"></span>
+ </div>
+ </div>
+ <div class="row" v-if="user.wallet && user.wallet.user_id == user.id">
+ <label class="col-sm-4 col-form-label">{{ $t('wallet.balance') }}</label>
+ <div class="col-sm-8">
+ <span :class="(user.wallet.balance < 0 ? 'text-danger' : 'text-success') + ' form-control-plaintext'">
+ {{ $root.price(user.wallet.balance, user.wallet.currency) }}
+ </span>
+ <span v-if="user.wallet.discount">
+ ({{ $t('user.discount') }}: {{ user.wallet.discount + '% - ' + user.wallet.discount_description }})
+ </span>
+ </div>
+ </div>
+ </form>
+ </modal-dialog>
</div>
</template>
<script>
+ import ModalDialog from '../Widgets/ModalDialog'
+
export default {
+ components: {
+ ModalDialog
+ },
data() {
return {
search: '',
+ user: {},
users: []
}
},
@@ -48,6 +137,9 @@
$('#search-box input', this.$el).focus()
},
methods: {
+ capitalize(str) {
+ return str.charAt(0).toUpperCase() + str.slice(1)
+ },
searchUser() {
this.users = []
@@ -66,6 +158,24 @@
})
.catch(this.$root.errorHandler)
},
+ summaryDialog(user_id) {
+ this.user = {}
+ this.$refs.summaryDialog.show()
+
+ axios.get('/api/v4/users/' + user_id + '/summary', { loader: '#summary-dialog form' } )
+ .then(response => {
+ this.user = response.data
+
+ for (const key in this.user.settings) {
+ this.user[key] = this.user.settings[key]
+ }
+
+ const country = this.user.country
+ if (country && country in window.config.countries) {
+ this.user.country = window.config.countries[country][1]
+ }
+ });
+ },
toDate(datetime) {
if (datetime) {
return datetime.split(' ')[0]
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -111,6 +111,7 @@
Route::post('users/{id}/resync', [API\V4\Admin\UsersController::class, 'resync']);
Route::get('users/{id}/skus', [API\V4\Admin\UsersController::class, 'skus']);
Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']);
+ Route::get('users/{id}/summary', [API\V4\Admin\UsersController::class, 'summary']);
Route::post('users/{id}/suspend', [API\V4\Admin\UsersController::class, 'suspend']);
Route::post('users/{id}/unsuspend', [API\V4\Admin\UsersController::class, 'unsuspend']);
@@ -170,6 +171,7 @@
Route::post('users/{id}/resync', [API\V4\Reseller\UsersController::class, 'resync']);
Route::get('users/{id}/skus', [API\V4\Reseller\UsersController::class, 'skus']);
Route::post('users/{id}/skus/{sku}', [API\V4\Reseller\UsersController::class, 'setSku']);
+ Route::get('users/{id}/summary', [API\V4\Reseller\UsersController::class, 'summary']);
Route::post('users/{id}/suspend', [API\V4\Reseller\UsersController::class, 'suspend']);
Route::post('users/{id}/unsuspend', [API\V4\Reseller\UsersController::class, 'unsuspend']);
diff --git a/src/tests/Browser/Admin/DashboardTest.php b/src/tests/Browser/Admin/DashboardTest.php
--- a/src/tests/Browser/Admin/DashboardTest.php
+++ b/src/tests/Browser/Admin/DashboardTest.php
@@ -7,6 +7,7 @@
use App\Utils;
use Illuminate\Support\Facades\Queue;
use Tests\Browser;
+use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
@@ -138,8 +139,8 @@
$browser->assertElementsCount('tbody tr', 1)
->assertVisible('tbody tr:first-child.text-secondary')
->with('tbody tr:first-child', static function (Browser $browser) use ($user) {
- $browser->assertSeeIn('td:nth-child(1) span', $user->email)
- ->assertSeeIn('td:nth-child(2) span', $user->id);
+ $browser->assertSeeIn('td:nth-child(1) a', $user->email)
+ ->assertSeeIn('td:nth-child(2) a', $user->id);
if ($browser->isPhone()) {
$browser->assertMissing('td:nth-child(3)');
@@ -150,7 +151,17 @@
->assertTextRegExp('td:nth-child(4)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/');
}
});
- });
+ })
+ ->click('@search table td a')
+ // Test the dialog content, and closing it with Cancel button
+ ->with(new Dialog('#summary-dialog'), static function (Browser $browser) use ($user) {
+ $browser->waitUntilMissing('form .loader')
+ ->assertSeeIn('@title', 'User summary')
+ ->assertSeeIn('@body', $user->email)
+ ->assertSeeIn('@button-cancel', 'Close')
+ ->click('@button-cancel');
+ })
+ ->waitUntilMissing('#summary-dialog');
});
}
}
diff --git a/src/tests/Browser/Reseller/DashboardTest.php b/src/tests/Browser/Reseller/DashboardTest.php
--- a/src/tests/Browser/Reseller/DashboardTest.php
+++ b/src/tests/Browser/Reseller/DashboardTest.php
@@ -134,8 +134,8 @@
$browser->assertElementsCount('tbody tr', 1)
->assertVisible('tbody tr:first-child.text-secondary')
->with('tbody tr:first-child', static function (Browser $browser) use ($user) {
- $browser->assertSeeIn('td:nth-child(1) span', $user->email)
- ->assertSeeIn('td:nth-child(2) span', $user->id);
+ $browser->assertSeeIn('td:nth-child(1) a', $user->email)
+ ->assertSeeIn('td:nth-child(2) a', $user->id);
if ($browser->isPhone()) {
$browser->assertMissing('td:nth-child(3)');
diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php
--- a/src/tests/Feature/Controller/Admin/UsersTest.php
+++ b/src/tests/Feature/Controller/Admin/UsersTest.php
@@ -496,6 +496,40 @@
$response->assertStatus(404);
}
+ /**
+ * Test user summary (GET /api/v4/users/<user-id>/summary)
+ */
+ public function testSummary(): void
+ {
+ Queue::fake();
+
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
+ $user->setSetting('first_name', 'Test');
+ $user->wallets->first()->setSetting('mollie_id', '123');
+ $user->delete();
+
+ // Test unauthorized access to admin API
+ $response = $this->actingAs($user)->get("/api/v4/users/{$user->id}/summary");
+ $response->assertStatus(403);
+
+ // Test unknown user
+ $response = $this->actingAs($admin)->get("/api/v4/users/unknown/summary");
+ $response->assertStatus(404);
+
+ // Test valid request
+ $response = $this->actingAs($admin)->get("/api/v4/users/{$user->id}/summary");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('mollie', $json['provider']);
+ $this->assertStringContainsString('/customers/123', $json['providerLink']);
+ $this->assertSame($user->email, $json['email']);
+ $this->assertSame('Test', $json['settings']['first_name']);
+ $this->assertSame($user->wallets->first()->id, $json['wallet']['id']);
+ }
+
/**
* Test user suspending (POST /api/v4/users/<user-id>/suspend)
*/

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 3, 4:49 PM (6 h, 45 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18823167
Default Alt Text
D5793.1775234953.diff (19 KB)

Event Timeline