Skip to content

Commit

Permalink
feat: allow configuring user/org socials on profile page (implements #…
Browse files Browse the repository at this point in the history
  • Loading branch information
MiniDigger committed Jan 4, 2025
1 parent 5c1ea91 commit d81b824
Show file tree
Hide file tree
Showing 14 changed files with 2,290 additions and 1,783 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
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;
Expand All @@ -45,12 +44,9 @@
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;
Expand All @@ -75,8 +71,6 @@
@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;
Expand Down Expand Up @@ -172,6 +166,27 @@ public void saveTagline(@PathVariable final String userName, @RequestBody final
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")
Expand Down Expand Up @@ -246,20 +261,8 @@ public void saveProfileSettings(@PathVariable final String userName, @RequestBod
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -194,6 +196,27 @@ public void saveTagline(@PathVariable final String orgName, @RequestBody final S
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
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;
Expand All @@ -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;
Expand Down Expand Up @@ -89,6 +96,17 @@ public void updateUser(final UserTable userTable) {
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TYPE logged_action_type ADD VALUE 'user_socials_changed'
5 changes: 5 additions & 0 deletions frontend/src/components/UserHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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" />

Expand Down
48 changes: 48 additions & 0 deletions frontend/src/components/form/SocialForm.vue
Original file line number Diff line number Diff line change
@@ -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 changes: 37 additions & 0 deletions frontend/src/components/modals/SocialsModal.vue
Original file line number Diff line number Diff line change
@@ -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>
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down Expand Up @@ -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",
Expand Down
42 changes: 3 additions & 39 deletions frontend/src/pages/auth/settings/profile.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts" setup>
import type { SettingsResponse } from "~/types/backend";
import SocialForm from "~/components/form/SocialForm.vue";
defineProps<{
settings?: SettingsResponse;
Expand All @@ -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;
Expand Down Expand Up @@ -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>
Expand Down
Loading

0 comments on commit d81b824

Please sign in to comment.