mirror of
https://github.com/HangarMC/Hangar.git
synced 2025-02-05 14:40:33 +08:00
feat: allow configuring user/org socials on profile page (implements #1395)
This commit is contained in:
parent
5c1ea9152b
commit
d81b824d3a
@ -25,7 +25,6 @@ import io.papermc.hangar.model.internal.user.notifications.HangarInvite;
|
||||
import io.papermc.hangar.model.internal.user.notifications.HangarNotification;
|
||||
import io.papermc.hangar.security.annotations.Anyone;
|
||||
import io.papermc.hangar.security.annotations.LoggedIn;
|
||||
import io.papermc.hangar.security.annotations.aal.RequireAal;
|
||||
import io.papermc.hangar.security.annotations.currentuser.CurrentUser;
|
||||
import io.papermc.hangar.security.annotations.permission.PermissionRequired;
|
||||
import io.papermc.hangar.security.annotations.ratelimit.RateLimit;
|
||||
@ -45,12 +44,9 @@ import io.papermc.hangar.service.internal.users.invites.ProjectInviteService;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@ -75,8 +71,6 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@RequestMapping(path = "/api/internal", produces = MediaType.APPLICATION_JSON_VALUE, method = {RequestMethod.GET, RequestMethod.POST})
|
||||
public class HangarUserController extends HangarComponent {
|
||||
|
||||
private static final Set<String> ACCEPTED_SOCIAL_TYPES = Set.of("discord", "github", "twitter", "youtube", "website");
|
||||
|
||||
private final UsersApiService usersApiService;
|
||||
private final UserService userService;
|
||||
private final NotificationService notificationService;
|
||||
@ -172,6 +166,27 @@ public class HangarUserController extends HangarComponent {
|
||||
this.actionLogger.user(LogAction.USER_TAGLINE_CHANGED.create(UserContext.of(userTable.getId()), userTable.getTagline(), oldTagline));
|
||||
}
|
||||
|
||||
// @el(userName: String)
|
||||
@Unlocked
|
||||
@CurrentUser("#userName")
|
||||
@ResponseStatus(HttpStatus.OK)
|
||||
@RateLimit(overdraft = 7, refillTokens = 1, refillSeconds = 20)
|
||||
@PermissionRequired(NamedPermission.EDIT_OWN_USER_SETTINGS)
|
||||
@PostMapping(path = "/users/{userName}/settings/socials", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public void saveSocials(@PathVariable final String userName, @RequestBody final Map<String, String> socials) {
|
||||
final UserTable userTable = this.userService.getUserTable(userName);
|
||||
if (userTable == null) {
|
||||
throw new HangarApiException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
this.userService.validateSocials(socials);
|
||||
|
||||
final JSONB oldSocials = userTable.getSocials() == null ? new JSONB("[]") : userTable.getSocials();
|
||||
userTable.setSocials(new JSONB(socials));
|
||||
this.userService.updateUser(userTable);
|
||||
this.actionLogger.user(LogAction.USER_SOCIALS_CHANGED.create(UserContext.of(userTable.getId()), userTable.getSocials().toString(), oldSocials.toString()));
|
||||
}
|
||||
|
||||
// @el(userName: String)
|
||||
@Unlocked
|
||||
@CurrentUser("#userName")
|
||||
@ -246,20 +261,8 @@ public class HangarUserController extends HangarComponent {
|
||||
throw new HangarApiException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
Map<String, String> map = new HashMap<>();
|
||||
for (final String[] social : settings.socials()) {
|
||||
if (social.length != 2) {
|
||||
throw new HangarApiException("Badly formatted request, " + Arrays.toString(social) + " wasn't of length 2!");
|
||||
}
|
||||
if (!ACCEPTED_SOCIAL_TYPES.contains(social[0])) {
|
||||
throw new HangarApiException("Badly formatted request, social type " + social[0] + " is unknown!");
|
||||
}
|
||||
if ("website".equals(social[0]) && !social[1].matches(this.config.getUrlRegex())) {
|
||||
throw new HangarApiException("Badly formatted request, website " + social[1] + " is not a valid url! (Did you add https://?)");
|
||||
}
|
||||
map.put(social[0], social[1]);
|
||||
}
|
||||
userTable.setSocials(new JSONB(map));
|
||||
this.userService.validateSocials(settings.socials());
|
||||
userTable.setSocials(new JSONB(settings.socials()));
|
||||
userTable.setTagline(settings.tagline());
|
||||
// TODO user action logging
|
||||
this.userService.updateUser(userTable);
|
||||
|
@ -1,7 +1,9 @@
|
||||
package io.papermc.hangar.controller.internal;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.papermc.hangar.HangarComponent;
|
||||
import io.papermc.hangar.components.images.service.AvatarService;
|
||||
import io.papermc.hangar.db.customtypes.JSONB;
|
||||
import io.papermc.hangar.exceptions.HangarApiException;
|
||||
import io.papermc.hangar.model.common.NamedPermission;
|
||||
import io.papermc.hangar.model.common.Permission;
|
||||
@ -194,6 +196,27 @@ public class OrganizationController extends HangarComponent {
|
||||
this.actionLogger.user(LogAction.USER_TAGLINE_CHANGED.create(UserContext.of(userTable.getId()), userTable.getTagline(), oldTagline));
|
||||
}
|
||||
|
||||
@Unlocked
|
||||
@RequireAal(1)
|
||||
@ResponseStatus(HttpStatus.OK)
|
||||
@RateLimit(overdraft = 7, refillTokens = 1, refillSeconds = 20)
|
||||
@PermissionRequired(type = PermissionType.ORGANIZATION, perms = NamedPermission.EDIT_SUBJECT_SETTINGS, args = "{#orgName}")
|
||||
@PostMapping(path = "/org/{orgName}/settings/socials", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public void saveSocials(@PathVariable final String orgName, @RequestBody final Map<String, String> socials) {
|
||||
final UserTable userTable = this.userService.getUserTable(orgName);
|
||||
final OrganizationTable organizationTable = this.organizationService.getOrganizationTable(orgName);
|
||||
if (userTable == null || organizationTable == null) {
|
||||
throw new HangarApiException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
this.userService.validateSocials(socials);
|
||||
|
||||
final JSONB oldSocials = userTable.getSocials() == null ? new JSONB("[]") : userTable.getSocials();
|
||||
userTable.setSocials(new JSONB(socials));
|
||||
this.userService.updateUser(userTable);
|
||||
this.actionLogger.user(LogAction.USER_SOCIALS_CHANGED.create(UserContext.of(userTable.getId()), userTable.getSocials().toString(), oldSocials.toString()));
|
||||
}
|
||||
|
||||
@Unlocked
|
||||
@RequireAal(1)
|
||||
@ResponseBody
|
||||
|
@ -45,6 +45,7 @@ public class PGLoggedAction extends PGobject {
|
||||
|
||||
// Users
|
||||
public static final PGLoggedAction USER_TAGLINE_CHANGED = new PGLoggedAction("user_tagline_changed");
|
||||
public static final PGLoggedAction USER_SOCIALS_CHANGED = new PGLoggedAction("user_socials_changed");
|
||||
public static final PGLoggedAction USER_LOCKED = new PGLoggedAction("user_locked");
|
||||
public static final PGLoggedAction USER_UNLOCKED = new PGLoggedAction("user_unlocked");
|
||||
public static final PGLoggedAction USER_APIKEY_CREATED = new PGLoggedAction("user_apikey_created");
|
||||
|
@ -1,3 +1,5 @@
|
||||
package io.papermc.hangar.model.internal.api.requests;
|
||||
|
||||
public record UserProfileSettings(String tagline, String[][] socials) {}
|
||||
import java.util.Map;
|
||||
|
||||
public record UserProfileSettings(String tagline, Map<String, String> socials) {}
|
||||
|
@ -55,6 +55,7 @@ public class LogAction<LC extends LogContext<? extends LoggedActionTable, LC>> {
|
||||
|
||||
// Users
|
||||
public static final LogAction<UserContext> USER_TAGLINE_CHANGED = new LogAction<>(PGLoggedAction.USER_TAGLINE_CHANGED, "User Tagline Changed");
|
||||
public static final LogAction<UserContext> USER_SOCIALS_CHANGED = new LogAction<>(PGLoggedAction.USER_SOCIALS_CHANGED, "User Socials Changed");
|
||||
public static final LogAction<UserContext> USER_LOCKED = new LogAction<>(PGLoggedAction.USER_LOCKED, "User Locked");
|
||||
public static final LogAction<UserContext> USER_UNLOCKED = new LogAction<>(PGLoggedAction.USER_UNLOCKED, "User Unlocked");
|
||||
public static final LogAction<UserContext> USER_APIKEY_CREATED = new LogAction<>(PGLoggedAction.USER_APIKEY_CREATED, "User Apikey Created");
|
||||
|
@ -3,11 +3,16 @@ package io.papermc.hangar.service.internal.users;
|
||||
import io.papermc.hangar.HangarComponent;
|
||||
import io.papermc.hangar.db.dao.internal.HangarUsersDAO;
|
||||
import io.papermc.hangar.db.dao.internal.table.UserDAO;
|
||||
import io.papermc.hangar.exceptions.HangarApiException;
|
||||
import io.papermc.hangar.model.common.Prompt;
|
||||
import io.papermc.hangar.model.db.UserTable;
|
||||
import io.papermc.hangar.model.internal.logs.LogAction;
|
||||
import io.papermc.hangar.model.internal.logs.LoggedAction;
|
||||
import io.papermc.hangar.model.internal.logs.contexts.UserContext;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
@ -22,6 +27,8 @@ public class UserService extends HangarComponent {
|
||||
private final UserDAO userDAO;
|
||||
private final HangarUsersDAO hangarUsersDAO;
|
||||
|
||||
private static final Set<String> ACCEPTED_SOCIAL_TYPES = Set.of("discord", "github", "twitter", "youtube", "website");
|
||||
|
||||
@Autowired
|
||||
public UserService(final UserDAO userDAO, final HangarUsersDAO hangarUsersDAO) {
|
||||
this.userDAO = userDAO;
|
||||
@ -89,6 +96,17 @@ public class UserService extends HangarComponent {
|
||||
this.userDAO.update(userTable);
|
||||
}
|
||||
|
||||
public void validateSocials(Map<String, String> socials) {
|
||||
for (final Map.Entry<String, String> social : socials.entrySet()) {
|
||||
if (!ACCEPTED_SOCIAL_TYPES.contains(social.getKey())) {
|
||||
throw new HangarApiException("Badly formatted request, social type " + social.getKey() + " is unknown!");
|
||||
}
|
||||
if ("website".equals(social.getKey()) && !social.getValue().matches(this.config.getUrlRegex())) {
|
||||
throw new HangarApiException("Badly formatted request, website " + social.getValue() + " is not a valid url! (Did you add https://?)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable <T> UserTable getUserTable(final @Nullable T identifier, final @NotNull Function<T, UserTable> userTableFunction) {
|
||||
if (identifier == null) {
|
||||
return null;
|
||||
|
@ -0,0 +1 @@
|
||||
ALTER TYPE logged_action_type ADD VALUE 'user_socials_changed'
|
@ -86,6 +86,11 @@ const canEditCurrentUser = computed<boolean>(() => {
|
||||
</div>
|
||||
</template>
|
||||
</Popper>
|
||||
<SocialsModal
|
||||
v-if="canEditCurrentUser"
|
||||
:socials="viewingUser.socials"
|
||||
:action="`${viewingUser.isOrganization ? 'organizations/org' : 'users'}/${viewingUser.name}/settings/socials`"
|
||||
/>
|
||||
</h1>
|
||||
<Skeleton v-else class="text-2xl px-1 w-50" />
|
||||
|
||||
|
48
frontend/src/components/form/SocialForm.vue
Normal file
48
frontend/src/components/form/SocialForm.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useI18n();
|
||||
const notification = useNotificationStore();
|
||||
|
||||
const socials = defineModel<Record<string, string>>({ required: true });
|
||||
|
||||
const linkType = ref<string>();
|
||||
const linkTypes = [
|
||||
{ value: "discord", text: "Discord" },
|
||||
{ value: "github", text: "GitHub" },
|
||||
{ value: "twitter", text: "Twitter" },
|
||||
{ value: "youtube", text: "YouTube" },
|
||||
{ value: "website", text: "Website" },
|
||||
];
|
||||
|
||||
function addLink() {
|
||||
if (!linkType.value) {
|
||||
return notification.error("You have to select a type");
|
||||
}
|
||||
if (Object.keys(socials.value).includes(linkType.value)) {
|
||||
return notification.error("You already have a link of that type added");
|
||||
}
|
||||
socials.value[linkType.value] = "";
|
||||
}
|
||||
|
||||
function removeLink(type: string) {
|
||||
delete socials.value[type];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="(_, type) in socials" :key="type" class="flex items-center mt-2">
|
||||
<span class="w-25">{{ linkTypes.find((e) => e.value === type)?.text }}</span>
|
||||
<div class="w-75">
|
||||
<InputText v-if="type === 'website'" v-model="socials[type]" label="URL" :rules="[required(), validUrl()]" />
|
||||
<InputText v-else v-model="socials[type]" :label="t('auth.settings.account.username')" :rules="[required()]" />
|
||||
</div>
|
||||
<IconMdiBin class="ml-2 w-6 h-6 cursor-pointer hover:color-red" @click="removeLink(type)" />
|
||||
</div>
|
||||
<div class="flex items-center mt-2">
|
||||
<div class="w-25">
|
||||
<Button button-type="secondary" @click.prevent="addLink">Add link</Button>
|
||||
</div>
|
||||
<div class="w-75">
|
||||
<InputSelect v-model="linkType" :values="linkTypes" :label="t('project.settings.links.typeField')" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
37
frontend/src/components/modals/SocialsModal.vue
Normal file
37
frontend/src/components/modals/SocialsModal.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<script lang="ts" setup>
|
||||
import type { JsonNode } from "~/types/backend";
|
||||
|
||||
const props = defineProps<{
|
||||
socials: JsonNode;
|
||||
action: string;
|
||||
}>();
|
||||
|
||||
const newSocials = ref(props.socials);
|
||||
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const loading = ref(false);
|
||||
|
||||
async function save() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await useInternalApi(props.action, "post", newSocials.value);
|
||||
router.go(0);
|
||||
} catch (err) {
|
||||
handleRequestError(err);
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="i18n.t('author.editSocials')" window-classes="w-200 text-lg">
|
||||
<SocialForm v-model="newSocials" />
|
||||
<Button class="mt-3" @click="save">{{ i18n.t("general.change") }}</Button>
|
||||
<template #activator="{ on }">
|
||||
<Button size="small" class="ml-2 inline-flex text-lg" v-on="on">
|
||||
<IconMdiPencil />
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
@ -787,6 +787,7 @@
|
||||
"viewOnForums": "View on forums ",
|
||||
"taglineLabel": "User Tagline",
|
||||
"editTagline": "Edit Tagline",
|
||||
"editSocials": "Edit Socials",
|
||||
"editOrgVisibility": "Edit Organization Visibility",
|
||||
"orgVisibilityModal": "Toggle an organization to hide your membership publicly.",
|
||||
"memberSince": "Member since {0}",
|
||||
@ -1137,6 +1138,7 @@
|
||||
"VersionPlatformDependencyAdded": "A platform dependency was added",
|
||||
"VersionPlatformDependencyRemoved": "A platform dependency was removed",
|
||||
"UserTaglineChanged": "The user tagline changed",
|
||||
"UserSocialsChanged": "The user socials changed",
|
||||
"UserLocked": "This user is locked",
|
||||
"UserUnlocked": "This user is unlocked",
|
||||
"UserApikeyCreated": "An apikey was created",
|
||||
|
@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SettingsResponse } from "~/types/backend";
|
||||
import SocialForm from "~/components/form/SocialForm.vue";
|
||||
|
||||
defineProps<{
|
||||
settings?: SettingsResponse;
|
||||
@ -15,30 +16,8 @@ const loading = ref(false);
|
||||
|
||||
const profileForm = reactive({
|
||||
tagline: auth.user?.tagline,
|
||||
socials: auth.user?.socials ? Object.entries(auth.user.socials) : [],
|
||||
socials: auth.user?.socials,
|
||||
});
|
||||
const linkType = ref<string>();
|
||||
const linkTypes = [
|
||||
{ value: "discord", text: "Discord" },
|
||||
{ value: "github", text: "GitHub" },
|
||||
{ value: "twitter", text: "Twitter" },
|
||||
{ value: "youtube", text: "YouTube" },
|
||||
{ value: "website", text: "Website" },
|
||||
];
|
||||
|
||||
function addLink() {
|
||||
if (!linkType.value) {
|
||||
return notification.error("You have to select a type");
|
||||
}
|
||||
if (profileForm.socials.some((e) => (e[0] as string) === linkType.value)) {
|
||||
return notification.error("You already have a link of that type added");
|
||||
}
|
||||
profileForm.socials.push([linkType.value, ""]);
|
||||
}
|
||||
|
||||
function removeLink(idx: number) {
|
||||
profileForm.socials.splice(idx, 1);
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (!(await v.value.$validate())) return;
|
||||
@ -71,22 +50,7 @@ async function saveProfile() {
|
||||
<InputText v-model="profileForm.tagline" :label="t('auth.settings.profile.tagline')" counter :maxlength="useBackendData.validations.userTagline.max" />
|
||||
|
||||
<h3 class="text-lg font-bold mt-4">{{ t("auth.settings.profile.social") }}</h3>
|
||||
<div v-for="(link, idx) in profileForm.socials" :key="link[0]" class="flex items-center mt-2">
|
||||
<span class="w-25">{{ linkTypes.find((e) => e.value === link[0])?.text }}</span>
|
||||
<div class="w-75">
|
||||
<InputText v-if="link[0] === 'website'" v-model="link[1]" label="URL" :rules="[required(), validUrl()]" />
|
||||
<InputText v-else v-model="link[1]" :label="t('auth.settings.account.username')" :rules="[required()]" />
|
||||
</div>
|
||||
<IconMdiBin class="ml-2 w-6 h-6 cursor-pointer hover:color-red" @click="removeLink(idx)" />
|
||||
</div>
|
||||
<div class="flex items-center mt-2">
|
||||
<div class="w-25">
|
||||
<Button button-type="secondary" @click.prevent="addLink">Add link</Button>
|
||||
</div>
|
||||
<div class="w-75">
|
||||
<InputSelect v-model="linkType" :values="linkTypes" :label="t('project.settings.links.typeField')" />
|
||||
</div>
|
||||
</div>
|
||||
<SocialForm v-model="profileForm.socials" />
|
||||
|
||||
<Button type="submit" class="w-max mt-2" :disabled="loading" @click.prevent="saveProfile">{{ t("general.save") }}</Button>
|
||||
</div>
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user