mirror of
https://github.com/HangarMC/Hangar.git
synced 2025-03-13 15:39:18 +08:00
feat(frontend): add username history, closes Hangar/HangarAuth#277
This commit is contained in:
parent
886d0d84f7
commit
aed7aa9cd4
@ -8,7 +8,9 @@ import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
@ConfigurationProperties(prefix = "hangar.users")
|
||||
public record UserConfig(
|
||||
@DefaultValue("100") int maxTaglineLen,
|
||||
@DefaultValue({"Hangar_Admin", "Hangar_Mod"}) List<String> staffRoles
|
||||
@DefaultValue({"Hangar_Admin", "Hangar_Mod"}) List<String> staffRoles,
|
||||
@DefaultValue("30") int nameChangeInterval,
|
||||
@DefaultValue("90") int nameChangeHistory
|
||||
) {
|
||||
|
||||
public Validation userTagline() {
|
||||
|
@ -4,6 +4,7 @@ import com.auth0.jwt.exceptions.JWTVerificationException;
|
||||
import com.auth0.jwt.interfaces.DecodedJWT;
|
||||
import io.papermc.hangar.HangarComponent;
|
||||
import io.papermc.hangar.exceptions.HangarApiException;
|
||||
import io.papermc.hangar.exceptions.WebHookException;
|
||||
import io.papermc.hangar.model.db.UserTable;
|
||||
import io.papermc.hangar.model.internal.sso.SsoSyncData;
|
||||
import io.papermc.hangar.model.internal.sso.URLWithNonce;
|
||||
@ -20,6 +21,7 @@ import javax.servlet.http.HttpSession;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.CookieValue;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@ -161,18 +163,22 @@ public class LoginController extends HangarComponent {
|
||||
|
||||
@PostMapping("/sync")
|
||||
@RateLimit(overdraft = 5, refillTokens = 2, refillSeconds = 10)
|
||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||
public void sync(@NotNull @RequestBody SsoSyncData body, @RequestHeader("X-Kratos-Hook-Api-Key") String apiKey) {
|
||||
public ResponseEntity sync(@NotNull @RequestBody SsoSyncData body, @RequestHeader("X-Kratos-Hook-Api-Key") String apiKey) {
|
||||
if (!apiKey.equals(config.sso.kratosApiKey())) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if (body.state().equals("active")) {
|
||||
logger.debug("Syncing {}'s new traits: {}", body.id(), body.traits());
|
||||
ssoService.sync(body.id(), body.traits());
|
||||
try {
|
||||
ssoService.sync(body.id(), body.traits());
|
||||
} catch (WebHookException ex) {
|
||||
return ResponseEntity.badRequest().body(ex.getError());
|
||||
}
|
||||
} else {
|
||||
logger.debug("Not syncing since its not active! {}", body);
|
||||
}
|
||||
return ResponseEntity.accepted().build();
|
||||
}
|
||||
|
||||
private RedirectView addBaseAndRedirect(String url) {
|
||||
|
@ -1,7 +1,10 @@
|
||||
package io.papermc.hangar.db.dao.internal.table;
|
||||
|
||||
import io.papermc.hangar.model.api.UserNameChange;
|
||||
import io.papermc.hangar.model.db.UserTable;
|
||||
import org.jdbi.v3.sqlobject.config.KeyColumn;
|
||||
import org.jdbi.v3.sqlobject.config.RegisterConstructorMapper;
|
||||
import org.jdbi.v3.sqlobject.config.ValueColumn;
|
||||
import org.jdbi.v3.sqlobject.customizer.BindBean;
|
||||
import org.jdbi.v3.sqlobject.customizer.Timestamped;
|
||||
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
|
||||
@ -10,7 +13,10 @@ import org.jdbi.v3.sqlobject.statement.SqlUpdate;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
@ -65,4 +71,12 @@ public interface UserDAO {
|
||||
LIMIT 49000
|
||||
""")
|
||||
List<String> getAuthorNames();
|
||||
|
||||
@RegisterConstructorMapper(UserNameChange.class)
|
||||
@SqlQuery("SELECT old_name, new_name, date FROM users_history WHERE uuid = :uuid ORDER BY date ASC")
|
||||
List<UserNameChange> getUserNameHistory(final @NotNull UUID uuid);
|
||||
|
||||
@Timestamped
|
||||
@SqlUpdate("INSERT INTO users_history(uuid, old_name, new_name, date) VALUES (:uuid, :oldName, :newName, :now)")
|
||||
void recordNameChange(final @NotNull UUID uuid, final @NotNull String oldName, final @NotNull String newName);
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package io.papermc.hangar.db.dao.v1;
|
||||
|
||||
import io.papermc.hangar.db.extras.BindPagination;
|
||||
import io.papermc.hangar.model.api.User;
|
||||
import io.papermc.hangar.model.api.UserNameChange;
|
||||
import io.papermc.hangar.model.api.project.ProjectCompact;
|
||||
import io.papermc.hangar.model.api.requests.RequestPagination;
|
||||
import org.jdbi.v3.sqlobject.config.RegisterConstructorMapper;
|
||||
@ -9,8 +10,10 @@ import org.jdbi.v3.sqlobject.customizer.BindList;
|
||||
import org.jdbi.v3.sqlobject.customizer.Define;
|
||||
import org.jdbi.v3.sqlobject.statement.SqlQuery;
|
||||
import org.jdbi.v3.stringtemplate4.UseStringTemplateEngine;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
@ -130,4 +133,14 @@ public interface UsersApiDAO {
|
||||
" JOIN roles r ON ugr.role_id = r.id" +
|
||||
" WHERE r.name in (<staffRoles>)")
|
||||
long getStaffCount(@BindList(onEmpty = BindList.EmptyHandling.NULL_STRING) List<String> staffRoles);
|
||||
|
||||
@RegisterConstructorMapper(UserNameChange.class)
|
||||
@SqlQuery("""
|
||||
SELECT uh.old_name, uh.new_name, uh.date
|
||||
FROM users_history uh
|
||||
JOIN users u on uh.uuid = u.uuid
|
||||
WHERE u.name = :name AND uh.date >= :date
|
||||
ORDER BY date DESC
|
||||
""")
|
||||
List<UserNameChange> getUserNameHistory(@NotNull String name, @NotNull OffsetDateTime date);
|
||||
}
|
||||
|
@ -0,0 +1,27 @@
|
||||
package io.papermc.hangar.exceptions;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class WebHookException extends RuntimeException {
|
||||
|
||||
private final Map<String, Object> error;
|
||||
|
||||
private WebHookException(final Map<String, Object> error) {
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public Map<String, Object> getError() {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
public static WebHookException of(String message) {
|
||||
return new WebHookException(Map.of("messages", List.of(Map.of(
|
||||
"instance_ptr", "#/method",
|
||||
"messages", List.of(
|
||||
Map.of("id", 123,
|
||||
"text", message,
|
||||
"type", "error")
|
||||
)))));
|
||||
}
|
||||
}
|
@ -9,6 +9,7 @@ import org.jdbi.v3.core.mapper.reflect.JdbiConstructor;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public class User extends Model implements Named {
|
||||
|
||||
@ -19,9 +20,10 @@ public class User extends Model implements Named {
|
||||
private final long projectCount;
|
||||
private final boolean isOrganization;
|
||||
private final boolean locked;
|
||||
private List<UserNameChange> nameHistory;
|
||||
|
||||
@JdbiConstructor
|
||||
public User(OffsetDateTime createdAt, String name, String tagline, OffsetDateTime joinDate, List<GlobalRole> roles, long projectCount, boolean locked) {
|
||||
public User(OffsetDateTime createdAt, String name, String tagline, OffsetDateTime joinDate, List<GlobalRole> roles, long projectCount, boolean locked, @Nullable List<UserNameChange> nameHistory) {
|
||||
super(createdAt);
|
||||
this.name = name;
|
||||
this.tagline = tagline;
|
||||
@ -30,6 +32,7 @@ public class User extends Model implements Named {
|
||||
this.projectCount = projectCount;
|
||||
this.isOrganization = roles.contains(GlobalRole.ORGANIZATION);
|
||||
this.locked = locked;
|
||||
this.nameHistory = nameHistory;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -62,6 +65,15 @@ public class User extends Model implements Named {
|
||||
return locked;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public List<UserNameChange> getNameHistory() {
|
||||
return this.nameHistory;
|
||||
}
|
||||
|
||||
public void setNameHistory(final List<UserNameChange> nameHistory) {
|
||||
this.nameHistory = nameHistory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
|
@ -0,0 +1,5 @@
|
||||
package io.papermc.hangar.model.api;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public record UserNameChange(String oldName, String newName, OffsetDateTime date) {}
|
@ -2,11 +2,13 @@ package io.papermc.hangar.model.internal.user;
|
||||
|
||||
import io.papermc.hangar.model.Identified;
|
||||
import io.papermc.hangar.model.api.User;
|
||||
import io.papermc.hangar.model.api.UserNameChange;
|
||||
import io.papermc.hangar.model.common.Permission;
|
||||
import io.papermc.hangar.model.common.roles.GlobalRole;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public class HangarUser extends User implements Identified {
|
||||
|
||||
@ -17,8 +19,8 @@ public class HangarUser extends User implements Identified {
|
||||
private final String theme;
|
||||
private String accessToken;
|
||||
|
||||
public HangarUser(OffsetDateTime createdAt, String name, String tagline, OffsetDateTime joinDate, List<GlobalRole> roles, long projectCount, boolean locked, long id, List<Integer> readPrompts, String language, String theme) {
|
||||
super(createdAt, name, tagline, joinDate, roles, projectCount, locked);
|
||||
public HangarUser(OffsetDateTime createdAt, String name, String tagline, OffsetDateTime joinDate, List<GlobalRole> roles, long projectCount, boolean locked, @Nullable List<UserNameChange> nameHistory, long id, List<Integer> readPrompts, String language, String theme) {
|
||||
super(createdAt, name, tagline, joinDate, roles, projectCount, locked, nameHistory);
|
||||
this.id = id;
|
||||
this.readPrompts = readPrompts;
|
||||
this.language = language;
|
||||
@ -66,7 +68,9 @@ public class HangarUser extends User implements Identified {
|
||||
this.getJoinDate(),
|
||||
this.getRoles(),
|
||||
this.getProjectCount(),
|
||||
this.isLocked());
|
||||
this.isLocked(),
|
||||
this.getNameHistory()
|
||||
);
|
||||
}
|
||||
|
||||
public static class HeaderData {
|
||||
|
@ -9,19 +9,24 @@ import io.papermc.hangar.exceptions.HangarApiException;
|
||||
import io.papermc.hangar.model.api.PaginatedResult;
|
||||
import io.papermc.hangar.model.api.Pagination;
|
||||
import io.papermc.hangar.model.api.User;
|
||||
import io.papermc.hangar.model.api.UserNameChange;
|
||||
import io.papermc.hangar.model.api.project.ProjectCompact;
|
||||
import io.papermc.hangar.model.api.project.ProjectSortingStrategy;
|
||||
import io.papermc.hangar.model.api.requests.RequestPagination;
|
||||
import io.papermc.hangar.model.common.Permission;
|
||||
import io.papermc.hangar.model.internal.user.HangarUser;
|
||||
import io.papermc.hangar.model.internal.user.HangarUser.HeaderData;
|
||||
import io.papermc.hangar.security.authentication.HangarPrincipal;
|
||||
import io.papermc.hangar.service.PermissionService;
|
||||
import io.papermc.hangar.service.internal.admin.FlagService;
|
||||
import io.papermc.hangar.service.internal.organizations.OrganizationService;
|
||||
import io.papermc.hangar.service.internal.projects.PinnedProjectService;
|
||||
import io.papermc.hangar.service.internal.projects.ProjectAdminService;
|
||||
import io.papermc.hangar.service.internal.versions.ReviewService;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.BiFunction;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
@ -62,6 +67,7 @@ public class UsersApiService extends HangarComponent {
|
||||
|
||||
public <T extends User> T getUser(String name, Class<T> type) {
|
||||
T user = getUserRequired(name, usersDAO::getUser, type);
|
||||
this.supplyNameHistory(user);
|
||||
return user instanceof HangarUser ? (T) supplyHeaderData((HangarUser) user) : user;
|
||||
}
|
||||
|
||||
@ -147,6 +153,17 @@ public class UsersApiService extends HangarComponent {
|
||||
return hangarUser;
|
||||
}
|
||||
|
||||
public void supplyNameHistory(final User user) {
|
||||
final Optional<HangarPrincipal> hangarPrincipal = this.getOptionalHangarPrincipal();
|
||||
final List<UserNameChange> userNameHistory;
|
||||
if (hangarPrincipal.isPresent() && hangarPrincipal.get().isAllowedGlobal(Permission.SeeHidden)) {
|
||||
userNameHistory = this.usersApiDAO.getUserNameHistory(user.getName(), OffsetDateTime.MIN);
|
||||
} else {
|
||||
userNameHistory = this.usersApiDAO.getUserNameHistory(user.getName(), OffsetDateTime.now().minus(this.config.user.nameChangeHistory(), ChronoUnit.DAYS));
|
||||
}
|
||||
user.setNameHistory(userNameHistory);
|
||||
}
|
||||
|
||||
public List<ProjectCompact> getUserPinned(String userName) {
|
||||
return pinnedProjectService.getPinnedVersions(getUserRequired(userName, usersDAO::getUser, HangarUser.class).getId());
|
||||
}
|
||||
|
@ -6,6 +6,9 @@ import io.papermc.hangar.config.hangar.HangarConfig;
|
||||
import io.papermc.hangar.db.dao.internal.table.UserDAO;
|
||||
import io.papermc.hangar.db.dao.internal.table.auth.UserOauthTokenDAO;
|
||||
import io.papermc.hangar.db.dao.internal.table.auth.UserSignOnDAO;
|
||||
import io.papermc.hangar.exceptions.HangarApiException;
|
||||
import io.papermc.hangar.exceptions.WebHookException;
|
||||
import io.papermc.hangar.model.api.UserNameChange;
|
||||
import io.papermc.hangar.model.db.UserTable;
|
||||
import io.papermc.hangar.model.db.auth.UserOauthTokenTable;
|
||||
import io.papermc.hangar.model.db.auth.UserSignOnTable;
|
||||
@ -17,7 +20,9 @@ import io.papermc.hangar.service.TokenService;
|
||||
import io.papermc.hangar.service.internal.projects.ProjectService;
|
||||
import java.math.BigInteger;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
@ -135,6 +140,7 @@ public class SSOService {
|
||||
user = this.userDAO.create(uuid, traits.username(), traits.email(), "", traits.language(), List.of(), false, traits.theme());
|
||||
} else {
|
||||
if (!user.getName().equals(traits.username())) {
|
||||
this.handleUsernameChange(user, traits.username());
|
||||
user.setName(traits.username());
|
||||
refreshHomeProjects = true; // must refresh this view when a username is changed
|
||||
}
|
||||
@ -155,6 +161,22 @@ public class SSOService {
|
||||
return user;
|
||||
}
|
||||
|
||||
private void handleUsernameChange(final UserTable user, final String newName) {
|
||||
// make sure a user with that name doesn't exist yet
|
||||
if (this.userDAO.getUserTable(newName) != null) {
|
||||
throw new HangarApiException("A user with that name already exists!");
|
||||
}
|
||||
// check that last change was long ago
|
||||
final List<UserNameChange> userNameHistory = this.userDAO.getUserNameHistory(user.getUuid());
|
||||
userNameHistory.sort(Comparator.comparing(UserNameChange::date).reversed());
|
||||
final OffsetDateTime nextChange = userNameHistory.get(0).date().plus(this.hangarConfig.user.nameChangeInterval(), ChronoUnit.DAYS);
|
||||
if (!userNameHistory.isEmpty() && nextChange.isAfter(OffsetDateTime.now())) {
|
||||
throw WebHookException.of("You can't change your name that soon! You have to wait till " + nextChange.format(DateTimeFormatter.RFC_1123_DATE_TIME));
|
||||
}
|
||||
// record the change into the db
|
||||
this.userDAO.recordNameChange(user.getUuid(), user.getName(), newName);
|
||||
}
|
||||
|
||||
public String redeemCode(final String code, final String redirect) {
|
||||
final HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
|
@ -132,7 +132,8 @@ hangar:
|
||||
- Hangar_Admin
|
||||
- Hangar_Mod
|
||||
- Hangar_Dev
|
||||
|
||||
name-change-interval: 30
|
||||
name-change-history: 90
|
||||
|
||||
sso:
|
||||
enabled: true
|
||||
|
@ -0,0 +1,9 @@
|
||||
create table users_history
|
||||
(
|
||||
uuid uuid not null
|
||||
constraint users_history_users_uuid_fk
|
||||
references users (uuid),
|
||||
old_name varchar(255) not null,
|
||||
new_name varchar(255) not null,
|
||||
date timestamp with time zone not null
|
||||
);
|
@ -31,10 +31,14 @@ async function fetch() {
|
||||
}).catch<any>((e) => handleRequestError(e));
|
||||
loading.value = false;
|
||||
if (!import.meta.env.SSR) {
|
||||
await nextTick(setupAdmonition);
|
||||
// if (typeof renderedMarkdown.value?.includes === "function" && renderedMarkdown.value?.includes("<code")) {
|
||||
await usePrismStore().handlePrism();
|
||||
// }
|
||||
console.log("before", loading.value, document.querySelectorAll('code[class*="language-"]'));
|
||||
await nextTick();
|
||||
console.log("after", loading.value, document.querySelectorAll('code[class*="language-"]'));
|
||||
setupAdmonition();
|
||||
if (typeof renderedMarkdown.value?.includes === "function" && renderedMarkdown.value?.includes("<code")) {
|
||||
await usePrismStore().handlePrism();
|
||||
}
|
||||
console.log("after2", loading.value, document.querySelectorAll('code[class*="language-"]'));
|
||||
}
|
||||
}
|
||||
await fetch();
|
||||
|
@ -3,6 +3,7 @@ import { Organization } from "hangar-internal";
|
||||
import { User } from "hangar-api";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { computed } from "vue";
|
||||
import { prettyDateTime } from "../lib/composables/useDate";
|
||||
import UserAvatar from "~/components/UserAvatar.vue";
|
||||
import { avatarUrl } from "~/composables/useUrlHelper";
|
||||
import Card from "~/lib/components/design/Card.vue";
|
||||
@ -14,6 +15,7 @@ import Tag from "~/components/Tag.vue";
|
||||
import AvatarChangeModal from "~/lib/components/modals/AvatarChangeModal.vue";
|
||||
import Tooltip from "~/lib/components/design/Tooltip.vue";
|
||||
import Button from "~/lib/components/design/Button.vue";
|
||||
import Popper from "~/lib/components/design/Popper.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
user: User;
|
||||
@ -63,6 +65,17 @@ const canEditCurrentUser = computed<boolean>(() => {
|
||||
<span v-if="user.locked" class="ml-1">
|
||||
<IconMdiLockOutline />
|
||||
</span>
|
||||
<span v-if="user.nameHistory?.length > 0" class="text-md">
|
||||
<Popper>
|
||||
<IconMdiChevronDown />
|
||||
<template #content="{ close }">
|
||||
<div class="-mt-2 p-2 rounded border-t-2 border-primary-400 background-default filter drop-shadow-xl flex flex-col text-lg" @click="close()">
|
||||
<div class="font-bold">Was known as:</div>
|
||||
<div v-for="(history, idx) of user.nameHistory" :key="idx">{{ history.oldName }} till {{ prettyDateTime(history.date) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popper>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div>
|
||||
|
1
frontend/src/types/api/users.d.ts
vendored
1
frontend/src/types/api/users.d.ts
vendored
@ -20,6 +20,7 @@ declare module "hangar-api" {
|
||||
projectCount: number;
|
||||
isOrganization: boolean;
|
||||
locked: boolean;
|
||||
nameHistory: { oldName: string; newName: string; date: string }[];
|
||||
}
|
||||
|
||||
interface ApiKey extends Model, Named {
|
||||
|
1
frontend/src/types/generated/icons.d.ts
vendored
1
frontend/src/types/generated/icons.d.ts
vendored
@ -25,6 +25,7 @@ declare module "@vue/runtime-core" {
|
||||
IconMdiCheckDecagram: typeof import("~icons/mdi/check-decagram")["default"];
|
||||
IconMdiCheckDecagramOutline: typeof import("~icons/mdi/check-decagram-outline")["default"];
|
||||
IconMdiChevronDoubleDown: typeof import("~icons/mdi/chevron-double-down")["default"];
|
||||
IconMdiChevronDown: typeof import("~icons/mdi/chevron-down")["default"];
|
||||
IconMdiCircle: typeof import("~icons/mdi/circle")["default"];
|
||||
IconMdiClipboardOutline: typeof import("~icons/mdi/clipboard-outline")["default"];
|
||||
IconMdiClose: typeof import("~icons/mdi/close")["default"];
|
||||
|
Loading…
x
Reference in New Issue
Block a user