Staff support dashboard routes (#3160)
* Staff support dashboard routes * Fix clippy
This commit is contained in:
parent
d7814e115d
commit
75b357a069
210
apps/frontend/src/pages/admin/billing/[id].vue
Normal file
210
apps/frontend/src/pages/admin/billing/[id].vue
Normal file
@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<NewModal ref="refundModal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">Refund charge</span>
|
||||
</template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="visibility" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Refund type
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span> The type of refund to issue. </span>
|
||||
</label>
|
||||
<DropdownSelect
|
||||
id="refund-type"
|
||||
v-model="refundType"
|
||||
:options="refundTypes"
|
||||
name="Refund type"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="refundType === 'partial'" class="flex flex-col gap-2">
|
||||
<label for="amount" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Amount
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span> Enter the amount in cents of USD. For example for $2, enter 200. </span>
|
||||
</label>
|
||||
<input id="amount" v-model="refundAmount" type="number" autocomplete="off" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="unprovision" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Unprovision
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span> Whether or not the subscription should be unprovisioned on refund. </span>
|
||||
</label>
|
||||
<Toggle
|
||||
id="unprovision"
|
||||
:model-value="unprovision"
|
||||
:checked="unprovision"
|
||||
@update:model-value="() => (unprovision = !unprovision)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="refundCharge">
|
||||
<CheckIcon aria-hidden="true" />
|
||||
Refund charge
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="refundModal.hide()">
|
||||
<XIcon aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div class="normal-page no-sidebar">
|
||||
<h1>{{ user.username }}'s subscriptions</h1>
|
||||
<div class="normal-page__content">
|
||||
<div v-for="subscription in subscriptionCharges" :key="subscription.id" class="card">
|
||||
<span class="font-extrabold text-contrast">
|
||||
<template v-if="subscription.product.metadata.type === 'midas'"> Modrinth Plus </template>
|
||||
<template v-else-if="subscription.product.metadata.type === 'pyro'">
|
||||
Modrinth Servers
|
||||
</template>
|
||||
<template v-else> Unknown product </template>
|
||||
<template v-if="subscription.interval">
|
||||
{{ subscription.interval }}
|
||||
</template>
|
||||
</span>
|
||||
<div class="mb-4 mt-2 flex items-center gap-1">
|
||||
{{ subscription.status }} ⋅ {{ $dayjs(subscription.created).format("YYYY-MM-DD") }}
|
||||
</div>
|
||||
<div
|
||||
v-for="charge in subscription.charges"
|
||||
:key="charge.id"
|
||||
class="universal-card recessed flex items-center justify-between gap-4"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge
|
||||
:color="charge.status === 'succeeded' ? 'green' : 'red'"
|
||||
:type="charge.status"
|
||||
/>
|
||||
⋅
|
||||
{{ $dayjs(charge.due).format("YYYY-MM-DD") }}
|
||||
⋅
|
||||
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
|
||||
<template v-if="subscription.interval"> ⋅ {{ subscription.interval }} </template>
|
||||
</div>
|
||||
<button
|
||||
v-if="charge.status === 'succeeded'"
|
||||
class="btn"
|
||||
@click="showRefundModal(charge)"
|
||||
>
|
||||
Refund charge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Badge, NewModal, ButtonStyled, DropdownSelect, Toggle } from "@modrinth/ui";
|
||||
import { formatPrice } from "@modrinth/utils";
|
||||
import { CheckIcon, XIcon } from "@modrinth/assets";
|
||||
import { products } from "~/generated/state.json";
|
||||
|
||||
const route = useRoute();
|
||||
const data = useNuxtApp();
|
||||
const vintl = useVIntl();
|
||||
const { formatMessage } = vintl;
|
||||
|
||||
const messages = defineMessages({
|
||||
userNotFoundError: {
|
||||
id: "admin.billing.error.not-found",
|
||||
defaultMessage: "User not found",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: user } = await useAsyncData(`user/${route.params.id}`, () =>
|
||||
useBaseFetch(`user/${route.params.id}`),
|
||||
);
|
||||
|
||||
if (!user.value) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: formatMessage(messages.userNotFoundError),
|
||||
});
|
||||
}
|
||||
|
||||
let subscriptions, charges;
|
||||
try {
|
||||
[{ data: subscriptions }, { data: charges }] = await Promise.all([
|
||||
useAsyncData(`billing/subscriptions?user_id=${route.params.id}`, () =>
|
||||
useBaseFetch(`billing/subscriptions?user_id=${user.value.id}`, {
|
||||
internal: true,
|
||||
}),
|
||||
),
|
||||
useAsyncData(`billing/payments?user_id=${route.params.id}`, () =>
|
||||
useBaseFetch(`billing/payments?user_id=${user.value.id}`, {
|
||||
internal: true,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
} catch {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: formatMessage(messages.userNotFoundError),
|
||||
});
|
||||
}
|
||||
|
||||
const subscriptionCharges = computed(() => {
|
||||
return subscriptions.value.map((subscription) => {
|
||||
return {
|
||||
...subscription,
|
||||
charges: charges.value.filter((charge) => charge.subscription_id === subscription.id),
|
||||
product: products.find((product) =>
|
||||
product.prices.some((price) => price.id === subscription.price_id),
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const refundModal = ref();
|
||||
const selectedCharge = ref(null);
|
||||
const refundType = ref("full");
|
||||
const refundTypes = ref(["full", "partial"]);
|
||||
const refundAmount = ref(0);
|
||||
const unprovision = ref(false);
|
||||
|
||||
function showRefundModal(charge) {
|
||||
selectedCharge.value = charge;
|
||||
refundType.value = "full";
|
||||
refundAmount.value = 0;
|
||||
unprovision.value = false;
|
||||
refundModal.value.show();
|
||||
}
|
||||
|
||||
async function refundCharge() {
|
||||
try {
|
||||
await useBaseFetch(`billing/charge/${selectedCharge.value.id}/refund`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: refundType.value,
|
||||
amount: refundAmount.value,
|
||||
unprovision: unprovision.value,
|
||||
}),
|
||||
internal: true,
|
||||
});
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "Error resubscribing",
|
||||
text: err.message ?? (err.data ? err.data.description : err),
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
</script>
|
||||
@ -25,7 +25,7 @@
|
||||
</template>
|
||||
</span>
|
||||
⋅
|
||||
<span>{{ formatPrice(charge.amount, charge.currency_code) }}</span>
|
||||
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge :color="charge.status === 'succeeded' ? 'green' : 'red'" :type="charge.status" />
|
||||
@ -39,6 +39,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, Badge } from "@modrinth/ui";
|
||||
import { formatPrice } from "@modrinth/utils";
|
||||
import { products } from "~/generated/state.json";
|
||||
|
||||
definePageMeta({
|
||||
@ -66,19 +67,4 @@ const { data: charges } = await useAsyncData(
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// TODO move to omorphia utils , duplicated from index
|
||||
function formatPrice(price, currency) {
|
||||
const formatter = new Intl.NumberFormat(vintl.locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
});
|
||||
|
||||
const maxDigits = formatter.resolvedOptions().maximumFractionDigits;
|
||||
|
||||
const convertedPrice = price / Math.pow(10, maxDigits);
|
||||
|
||||
return formatter.format(convertedPrice);
|
||||
}
|
||||
console.log(charges);
|
||||
</script>
|
||||
|
||||
@ -2,6 +2,57 @@
|
||||
<div v-if="user" class="experimental-styles-within">
|
||||
<ModalCreation ref="modal_creation" />
|
||||
<CollectionCreateModal ref="modal_collection_creation" />
|
||||
<NewModal v-if="auth.user && isStaff(auth.user)" ref="userDetailsModal" header="User details">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary">Email</span>
|
||||
<div>
|
||||
<span
|
||||
v-tooltip="user.email_verified ? 'Email verified' : 'Email not verified'"
|
||||
class="flex w-fit items-center gap-1"
|
||||
>
|
||||
<span>{{ user.email }}</span>
|
||||
<CheckIcon v-if="user.email_verified" class="h-4 w-4 text-brand" />
|
||||
<XIcon v-else class="h-4 w-4 text-red" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Auth providers </span>
|
||||
<span>{{ user.auth_providers.join(", ") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Payment methods</span>
|
||||
<span>
|
||||
<template v-if="user.payout_data?.paypal_address">
|
||||
Paypal ({{ user.payout_data.paypal_address }} - {{ user.payout_data.paypal_country }})
|
||||
</template>
|
||||
<template v-if="user.payout_data?.paypal_address && user.payout_data?.venmo_address">
|
||||
,
|
||||
</template>
|
||||
<template v-if="user.payout_data?.venmo_address">
|
||||
Venmo ({{ user.payout_data.venmo_address }})
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Has password </span>
|
||||
<span>
|
||||
{{ user.has_password ? "Yes" : "No" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Has TOTP </span>
|
||||
<span>
|
||||
{{ user.has_totp ? "Yes" : "No" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div class="new-page sidebar" :class="{ 'alt-layout': cosmetics.leftContentLayout }">
|
||||
<div class="normal-page__header py-4">
|
||||
<ContentPageHeader>
|
||||
@ -74,6 +125,16 @@
|
||||
shown: auth.user?.id !== user.id,
|
||||
},
|
||||
{ id: 'copy-id', action: () => copyId() },
|
||||
{
|
||||
id: 'open-billing',
|
||||
action: () => navigateTo(`/admin/billing/${user.id}`),
|
||||
shown: auth.user && isStaff(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'open-info',
|
||||
action: () => $refs.userDetailsModal.show(),
|
||||
shown: auth.user && isStaff(auth.user),
|
||||
},
|
||||
]"
|
||||
aria-label="More options"
|
||||
>
|
||||
@ -90,6 +151,14 @@
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.copyIdButton) }}
|
||||
</template>
|
||||
<template #open-billing>
|
||||
<CurrencyIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.billingButton) }}
|
||||
</template>
|
||||
<template #open-info>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.infoButton) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@ -264,8 +333,18 @@ import {
|
||||
DownloadIcon,
|
||||
ClipboardCopyIcon,
|
||||
MoreVerticalIcon,
|
||||
CurrencyIcon,
|
||||
InfoIcon,
|
||||
CheckIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { OverflowMenu, ButtonStyled, ContentPageHeader, commonMessages } from "@modrinth/ui";
|
||||
import {
|
||||
OverflowMenu,
|
||||
ButtonStyled,
|
||||
ContentPageHeader,
|
||||
commonMessages,
|
||||
NewModal,
|
||||
} from "@modrinth/ui";
|
||||
import { isStaff } from "~/helpers/users.js";
|
||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
import ProjectCard from "~/components/ui/ProjectCard.vue";
|
||||
import { reportUser } from "~/utils/report-helpers.ts";
|
||||
@ -367,6 +446,14 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
"You don't have any collections.\nWould you like to <create-link>create one</create-link>?",
|
||||
},
|
||||
billingButton: {
|
||||
id: "profile.button.billing",
|
||||
defaultMessage: "Manage user billing",
|
||||
},
|
||||
infoButton: {
|
||||
id: "profile.button.info",
|
||||
defaultMessage: "View user details",
|
||||
},
|
||||
userNotFoundError: {
|
||||
id: "profile.error.not-found",
|
||||
defaultMessage: "User not found",
|
||||
|
||||
@ -757,12 +757,7 @@ impl VersionField {
|
||||
l.field_id.0,
|
||||
l.version_id.0,
|
||||
l.int_value,
|
||||
if let Some(enum_value) = l.enum_value.as_ref().map(|e| e.0)
|
||||
{
|
||||
enum_value
|
||||
} else {
|
||||
-1
|
||||
},
|
||||
l.enum_value.as_ref().map(|e| e.0).unwrap_or(-1),
|
||||
l.string_value.clone(),
|
||||
)
|
||||
})
|
||||
|
||||
@ -83,12 +83,18 @@ pub async fn products(
|
||||
Ok(HttpResponse::Ok().json(products))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SubscriptionsQuery {
|
||||
pub user_id: Option<crate::models::ids::UserId>,
|
||||
}
|
||||
|
||||
#[get("subscriptions")]
|
||||
pub async fn subscriptions(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
query: web::Query<SubscriptionsQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
@ -102,7 +108,18 @@ pub async fn subscriptions(
|
||||
|
||||
let subscriptions =
|
||||
user_subscription_item::UserSubscriptionItem::get_all_user(
|
||||
user.id.into(),
|
||||
if let Some(user_id) = query.user_id {
|
||||
if user.role.is_admin() {
|
||||
user_id.into()
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You cannot see the subscriptions of other users!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
user.id.into()
|
||||
},
|
||||
&**pool,
|
||||
)
|
||||
.await?
|
||||
@ -573,12 +590,18 @@ pub async fn user_customer(
|
||||
Ok(HttpResponse::Ok().json(customer))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChargesQuery {
|
||||
pub user_id: Option<crate::models::ids::UserId>,
|
||||
}
|
||||
|
||||
#[get("payments")]
|
||||
pub async fn charges(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
query: web::Query<ChargesQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
@ -592,7 +615,18 @@ pub async fn charges(
|
||||
|
||||
let charges =
|
||||
crate::database::models::charge_item::ChargeItem::get_from_user(
|
||||
user.id.into(),
|
||||
if let Some(user_id) = query.user_id {
|
||||
if user.role.is_admin() {
|
||||
user_id.into()
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You cannot see the subscriptions of other users!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
user.id.into()
|
||||
},
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@ -85,11 +85,13 @@ pub async fn users_get(
|
||||
|
||||
#[get("{id}")]
|
||||
pub async fn user_get(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let response = v3::users::user_get(info, pool, redis)
|
||||
let response = v3::users::user_get(req, info, pool, redis, session_queue)
|
||||
.await
|
||||
.or_else(v2_reroute::flatten_404_error)?;
|
||||
|
||||
|
||||
@ -86,8 +86,6 @@ pub enum CreateError {
|
||||
CustomAuthenticationError(String),
|
||||
#[error("Image Parsing Error: {0}")]
|
||||
ImageError(#[from] ImageError),
|
||||
#[error("Reroute Error: {0}")]
|
||||
RerouteError(#[from] reqwest::Error),
|
||||
}
|
||||
|
||||
impl actix_web::ResponseError for CreateError {
|
||||
@ -119,7 +117,6 @@ impl actix_web::ResponseError for CreateError {
|
||||
CreateError::ValidationError(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::ImageError(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::RerouteError(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
@ -146,7 +143,6 @@ impl actix_web::ResponseError for CreateError {
|
||||
CreateError::ValidationError(..) => "invalid_input",
|
||||
CreateError::FileValidationError(..) => "invalid_input",
|
||||
CreateError::ImageError(..) => "invalid_image",
|
||||
CreateError::RerouteError(..) => "reroute_error",
|
||||
},
|
||||
description: self.to_string(),
|
||||
})
|
||||
|
||||
@ -128,14 +128,33 @@ pub async fn users_get(
|
||||
}
|
||||
|
||||
pub async fn user_get(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user_data = User::get(&info.into_inner().0, &**pool, &redis).await?;
|
||||
|
||||
if let Some(data) = user_data {
|
||||
let response: crate::models::users::User = data.into();
|
||||
let auth_user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::SESSION_ACCESS]),
|
||||
)
|
||||
.await
|
||||
.map(|x| x.1)
|
||||
.ok();
|
||||
|
||||
let response: crate::models::users::User =
|
||||
if auth_user.map(|x| x.role.is_admin()).unwrap_or(false) {
|
||||
crate::models::users::User::from_full(data)
|
||||
} else {
|
||||
data.into()
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
|
||||
@ -985,7 +985,7 @@ pub async fn upload_file(
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let delphi_url = dotenvy::var("DELPHI_URL")?;
|
||||
let res = client
|
||||
match client
|
||||
.post(delphi_url)
|
||||
.json(&serde_json::json!({
|
||||
"url": url,
|
||||
@ -993,11 +993,17 @@ pub async fn upload_file(
|
||||
"version_id": version_id,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
.await
|
||||
{
|
||||
Ok(res) => {
|
||||
if !res.status().is_success() {
|
||||
error!("Failed to upload file to Delphi: {url}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to upload file to Delphi: {url}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
version_files.push(VersionFileBuilder {
|
||||
filename: file_name.to_string(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user