user tagline editing

This commit is contained in:
Jake Potrebic 2021-03-17 16:55:48 -07:00
parent 1fbfd4cbed
commit 631ffeb43e
No known key found for this signature in database
GPG Key ID: 7C58557EC9C421F8
14 changed files with 232 additions and 113 deletions

View File

@ -85,10 +85,17 @@ export class HangarModal extends Vue {
activatorClass!: string;
@Watch('dialog')
onToggleView() {
if (typeof this.$refs.modalForm !== 'undefined') {
// @ts-ignore
this.$refs.modalForm.reset();
onToggleView(val: boolean) {
if (!val) {
this.$nextTick(() => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
if (typeof this.$refs.modalForm !== 'undefined') {
// @ts-ignore
this.$refs.modalForm.reset();
}
});
}
}
}

View File

@ -0,0 +1,71 @@
<template>
<v-dialog v-model="dialog" :max-width="maxWidth" @input="$emit('open')">
<template #activator="props">
<slot name="activator" v-bind="props" />
</template>
<v-card>
<v-card-title>{{ title }}</v-card-title>
<v-card-text>
<v-form ref="modalForm" v-model="validForm">
<slot />
</v-form>
</v-card-text>
<v-card-actions class="justify-end">
<v-btn text color="warning" @click.stop="close">{{ $t('general.close') }}</v-btn>
<slot name="other-btns" />
<v-btn color="success" :disabled="!validForm || submitDisabled" :loading="loading" @click.stop="submit0">
{{ submitLabel }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { Component, Prop } from 'nuxt-property-decorator';
import { PropType } from 'vue';
import { TranslateResult } from 'vue-i18n';
import { HangarFormModal } from '~/components/mixins';
@Component
export default class HangarModal extends HangarFormModal {
@Prop({ type: String, default: '500' })
maxWidth!: string;
@Prop({ type: String as PropType<TranslateResult>, required: true })
title!: TranslateResult;
@Prop({ type: String as PropType<TranslateResult>, required: true })
submitLabel!: TranslateResult;
@Prop({ type: Function as PropType<() => Promise<void>>, required: true })
submit!: () => Promise<void>;
@Prop({ type: Boolean, default: false })
submitDisabled!: boolean;
$refs!: {
modalForm: any;
};
close() {
this.$refs.modalForm.reset();
this.dialog = false;
this.$emit('close');
}
submit0() {
this.loading = true;
this.submit()
.then(() => {
this.dialog = false;
this.$refs.modalForm.reset();
})
.finally(() => {
this.loading = false;
});
}
}
</script>
<style lang="scss" scoped></style>

View File

@ -6,7 +6,7 @@
<v-card>
<v-card-title>{{ title }}</v-card-title>
<v-card-text>
<v-form ref="messageForm" v-model="validForm">
<v-form ref="modalForm" v-model="validForm">
<v-textarea
v-model.trim="message"
autofocus
@ -28,7 +28,7 @@
</template>
<script lang="ts">
import { Component, Prop, Watch } from 'nuxt-property-decorator';
import { Component, Prop } from 'nuxt-property-decorator';
import { PropType } from 'vue';
import { TranslateResult } from 'vue-i18n';
import { HangarFormModal } from '~/components/mixins';
@ -57,19 +57,6 @@ export default class TextareaModal extends HangarFormModal {
this.dialog = false;
});
}
@Watch('dialog')
onToggle(val: boolean) {
if (val) {
this.loading = false;
this.$nextTick(() => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
this.$refs.messageForm.reset();
});
}
}
}
</script>

View File

@ -6,6 +6,7 @@ const msgs: LocaleMessageObject = {
submit: 'Submit',
save: 'Save',
comment: 'Comment',
change: 'Change',
donate: 'Donate',
continue: 'Continue',
create: 'Create',
@ -25,8 +26,8 @@ const msgs: LocaleMessageObject = {
projectSearch: {
query: 'Search in {0} projects, proudly made by the community...',
relevanceSort: 'Sort with relevance',
noProjects: 'There are no projects. :/',
noProjectsFound: 'Found 0 projects. :/',
noProjects: 'There are no projects. 😢',
noProjectsFound: 'Found 0 projects. 😢',
},
subtitle: 'A Minecraft package repository',
sponsoredBy: 'Sponsored by',
@ -67,9 +68,9 @@ const msgs: LocaleMessageObject = {
},
project: {
stargazers: 'Stargazers',
noStargazers: 'There are no stargazers on this project yet :/',
noStargazers: 'There are no stargazers on this project yet 😢',
watchers: 'Watchers',
noWatchers: 'There are no watchers on this project yet :/',
noWatchers: 'There are no watchers on this project yet 😢',
members: 'Members',
category: {
info: 'Category: {0}',
@ -394,6 +395,8 @@ const msgs: LocaleMessageObject = {
stars: 'Stars',
orgs: 'Organizations',
viewOnForums: 'View on forums ',
taglineLabel: 'User Tagline',
editTagline: 'Edit Tagline',
memberSince: 'A member since {0}',
numProjects: 'No projects | {0} project | {0} projects',
addTagline: 'Add a tagline',
@ -408,6 +411,9 @@ const msgs: LocaleMessageObject = {
activity: 'User Activity',
admin: 'User Admin',
},
error: {
invalidTagline: 'Invalid tagline',
},
},
linkout: {
title: 'External Link Warning',
@ -511,7 +517,10 @@ const msgs: LocaleMessageObject = {
addVersion: 'Add Version',
saveChanges: 'Save Changes',
},
message: 'Good morning!',
validation: {
required: '{0} is required',
maxLength: 'Max length is {0}',
},
};
export default msgs;

View File

@ -28,12 +28,34 @@
</v-list>
<div>
<v-subheader>
<template v-if="user.tagline">{{ user.tagline }}</template>
<!-- TODO tagline edit -->
<!--<template v-else-if="u.isCurrent() || canEditOrgSettings(u, o, so)">{{ $t('author.addTagline') }}</template>-->
<v-btn icon>
<v-icon>mdi-pencil</v-icon>
</v-btn>
<span v-if="user.tagline">{{ user.tagline }}</span>
<span v-else-if="canEditCurrent">{{ $t('author.addTagline') }}</span>
<HangarModal
v-if="canEditCurrent"
ref="taglineModal"
:title="$t('author.editTagline')"
:submit-label="$t('general.change')"
:submit-disabled="taglineForm === user.tagline"
:submit="changeTagline"
@open="taglineForm = user.tagline"
>
<template #activator="{ on, attrs }">
<v-btn icon small color="warning" v-bind="attrs" v-on="on">
<v-icon small>mdi-pencil</v-icon>
</v-btn>
</template>
<v-text-field
v-model.trim="taglineForm"
counter="100"
:label="$t('author.taglineLabel')"
:rules="[$util.$vc.require($t('author.taglineLabel')), $util.$vc.maxLength(100)]"
/>
<template #other-btns>
<v-btn color="info" text :loading="loading.resetTagline" :disabled="!user.tagline" @click.stop="resetTagline">{{
$t('general.reset')
}}</v-btn>
</template>
</HangarModal>
</v-subheader>
</div>
</v-col>
@ -41,7 +63,7 @@
<v-col cols="2">
<v-subheader>{{ $tc('author.numProjects', user.projectCount, [user.projectCount]) }}</v-subheader>
<v-subheader>{{ $t('author.memberSince', [$util.prettyDate(user.joinDate)]) }}</v-subheader>
<a :href="$util.forumUrl(user.name)">{{ $t('author.viewOnForums') }}<v-icon>mdi-open-in-new</v-icon></a>
<a :href="$util.forumUrl(user.name)">{{ $t('author.viewOnForums') }}<v-icon small>mdi-open-in-new</v-icon></a>
</v-col>
</v-row>
<v-divider />
@ -54,6 +76,7 @@ import { Component, Vue } from 'nuxt-property-decorator';
import { HangarUser } from 'hangar-internal';
import { Context } from '@nuxt/types';
import UserAvatar from '../components/UserAvatar.vue';
import HangarModal from '~/components/modals/HangarModal.vue';
interface Button {
icon: string;
@ -64,10 +87,14 @@ interface Button {
}
@Component({
components: { UserAvatar },
components: { HangarModal, UserAvatar },
})
export default class UserParentPage extends Vue {
user!: HangarUser;
taglineForm: string | null = null;
loading = {
resetTagline: false,
};
get buttons(): Button[] {
const buttons = [] as Button[];
@ -80,11 +107,44 @@ export default class UserParentPage extends Vue {
return buttons;
}
get canEditCurrent() {
return this.user.id === this.$store.state.auth.user.id /* || org perms */;
}
get avatarClazz(): String {
return 'user-avatar-md';
// todo check org an add 'organization-avatar'
}
changeTagline() {
return this.$api
.requestInternal(`users/${this.user.id}/settings/tagline`, true, 'post', {
content: this.taglineForm,
})
.then(() => {
this.$nuxt.refresh();
})
.catch(this.$util.handleRequestError);
}
$refs!: {
taglineModal: HangarModal;
};
resetTagline() {
this.loading.resetTagline = true;
this.$api
.requestInternal(`users/${this.user.id}/settings/resetTagline`, true, 'post')
.then(() => {
this.$refs.taglineModal.close();
this.$nuxt.refresh();
})
.catch(this.$util.handleRequestError)
.finally(() => {
this.loading.resetTagline = false;
});
}
async asyncData({ $api, $util, params }: Context) {
const user = await $api.requestInternal<HangarUser>(`users/${params.user}`, false).catch<any>($util.handlePageRequestError);
if (typeof user === 'undefined') return;

View File

@ -14,7 +14,6 @@ import { NotifPayload } from '~/store/snackbar';
import { AuthState } from '~/store/auth';
type Validation = (v: any) => boolean | string;
type ValidationArgument = (any: any) => Validation;
function handleRequestError(err: AxiosError, error: Context['error'], i18n: Context['app']['i18n']) {
if (!err.isAxiosError) {
@ -220,9 +219,10 @@ const createUtil = ({ store, error, app: { i18n } }: Context) => {
}
}
$vc: Record<string, ValidationArgument> = {
require: ((name: string | TranslateResult = 'Field') => (v: string) => !!v || `${name} is required`) as ValidationArgument,
requireNonEmptyArray: ((name: string | TranslateResult = 'Field') => (v: any[]) => v.length > 0 || `${name} is required`) as ValidationArgument,
$vc = {
require: (name: TranslateResult = 'Field') => (v: string) => !!v || i18n.t('validation.required', [name]),
maxLength: (maxLength: number) => (v: string) => (!!v && v.length <= maxLength) || i18n.t('validation.maxLength', [maxLength]),
requireNonEmptyArray: (name: TranslateResult = 'Field') => (v: any[]) => v.length > 0 || i18n.t('validation.required', [name]),
};
$v: Record<string, Validation> = {};

View File

@ -3,6 +3,7 @@ package io.papermc.hangar.controller.extras;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@Deprecated(forRemoval = true)
public class ApiScope {
private final ApiScopeType type;
@ -65,6 +66,7 @@ public class ApiScope {
return new ApiScope(ApiScopeType.ORGANIZATION, organizationName);
}
@Deprecated(forRemoval = true)
public enum ApiScopeType {
GLOBAL,
PROJECT,

View File

@ -1,34 +0,0 @@
package io.papermc.hangar.controller.extras;
import io.papermc.hangar.model.common.Permission;
import io.papermc.hangar.model.db.UserTable;
import io.papermc.hangar.model.db.auth.ApiKeyTable;
import org.jdbi.v3.core.mapper.Nested;
import java.time.OffsetDateTime;
public class HangarApiRequest extends HangarRequest {
private final ApiKeyTable apiKeyTable;
private final String session;
private final OffsetDateTime expires;
public HangarApiRequest(@Nested("u") UserTable userTable, @Nested("ak") ApiKeyTable apiKeyTable, String session, OffsetDateTime expires, Permission globalPermissions) {
super(userTable, globalPermissions);
this.apiKeyTable = apiKeyTable;
this.session = session;
this.expires = expires;
}
public ApiKeyTable getApiKeyTable() {
return apiKeyTable;
}
public String getSession() {
return session;
}
public OffsetDateTime getExpires() {
return expires;
}
}

View File

@ -1,39 +0,0 @@
package io.papermc.hangar.controller.extras;
import io.papermc.hangar.model.common.Permission;
import io.papermc.hangar.model.db.UserTable;
public class HangarRequest {
private final UserTable userTable;
private final Permission globalPermissions;
public HangarRequest(UserTable userTable, Permission globalPermissions) {
this.userTable = userTable;
this.globalPermissions = globalPermissions;
}
public boolean hasUser() {
return userTable != null;
}
public Long getUserId() {
return userTable == null ? null : userTable.getId();
}
public UserTable getUserTable() {
return userTable;
}
public Permission getGlobalPermissions() {
return globalPermissions;
}
@Override
public String toString() {
return "HangarRequest{" +
"userTable=" + userTable +
", globalPermissions=" + globalPermissions +
'}';
}
}

View File

@ -3,19 +3,29 @@ package io.papermc.hangar.controller.internal;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.papermc.hangar.controller.HangarController;
import io.papermc.hangar.db.customtypes.LoggedActionType;
import io.papermc.hangar.db.customtypes.LoggedActionType.UserContext;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.model.common.NamedPermission;
import io.papermc.hangar.model.common.roles.Role;
import io.papermc.hangar.model.db.UserTable;
import io.papermc.hangar.model.db.roles.ExtendedRoleTable;
import io.papermc.hangar.model.internal.api.requests.StringContent;
import io.papermc.hangar.model.internal.user.HangarUser;
import io.papermc.hangar.model.internal.user.notifications.HangarNotification;
import io.papermc.hangar.security.HangarAuthenticationToken;
import io.papermc.hangar.security.annotations.permission.PermissionRequired;
import io.papermc.hangar.security.annotations.unlocked.Unlocked;
import io.papermc.hangar.service.api.UsersApiService;
import io.papermc.hangar.service.internal.roles.MemberService;
import io.papermc.hangar.service.internal.roles.MemberService.OrganizationMemberService;
import io.papermc.hangar.service.internal.roles.MemberService.ProjectMemberService;
import io.papermc.hangar.service.internal.roles.OrganizationRoleService;
import io.papermc.hangar.service.internal.roles.ProjectRoleService;
import io.papermc.hangar.service.internal.roles.RoleService;
import io.papermc.hangar.service.internal.users.InviteService;
import io.papermc.hangar.service.internal.users.NotificationService;
import io.papermc.hangar.service.internal.users.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@ -25,10 +35,12 @@ import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseStatus;
import javax.validation.Valid;
import java.util.List;
@Controller
@ -38,6 +50,7 @@ public class HangarUserController extends HangarController {
private final ObjectMapper mapper;
private final UsersApiService usersApiService;
private final UserService userService;
private final NotificationService notificationService;
private final InviteService inviteService;
private final ProjectRoleService projectRoleService;
@ -46,9 +59,10 @@ public class HangarUserController extends HangarController {
private final MemberService.OrganizationMemberService organizationMemberService;
@Autowired
public HangarUserController(ObjectMapper mapper, UsersApiService usersApiService, NotificationService notificationService, InviteService inviteService, ProjectRoleService projectRoleService, OrganizationRoleService organizationRoleService, MemberService.ProjectMemberService projectMemberService, MemberService.OrganizationMemberService organizationMemberService) {
public HangarUserController(ObjectMapper mapper, UsersApiService usersApiService, UserService userService, NotificationService notificationService, InviteService inviteService, ProjectRoleService projectRoleService, OrganizationRoleService organizationRoleService, ProjectMemberService projectMemberService, OrganizationMemberService organizationMemberService) {
this.mapper = mapper;
this.usersApiService = usersApiService;
this.userService = userService;
this.notificationService = notificationService;
this.inviteService = inviteService;
this.projectRoleService = projectRoleService;
@ -67,6 +81,42 @@ public class HangarUserController extends HangarController {
return ResponseEntity.ok(usersApiService.getUser(userName, HangarUser.class));
}
@Unlocked
@ResponseStatus(HttpStatus.OK)
@PermissionRequired(perms = NamedPermission.EDIT_OWN_USER_SETTINGS)
@PostMapping(path = "/users/{userId}/settings/tagline", consumes = MediaType.APPLICATION_JSON_VALUE)
public void saveTagline(@PathVariable long userId, @Valid @RequestBody StringContent content) {
UserTable userTable = userService.getUserTable(userId);
if (userTable == null) {
throw new HangarApiException(HttpStatus.NOT_FOUND);
}
if (content.getContent().length() > hangarConfig.user.getMaxTaglineLen()) {
throw new HangarApiException(HttpStatus.BAD_REQUEST, "author.error.invalidTagline");
}
String oldTagline = userTable.getTagline() == null ? "" : userTable.getTagline();
userTable.setTagline(content.getContent());
userService.updateUser(userTable);
userActionLogService.user(LoggedActionType.USER_TAGLINE_CHANGED.with(UserContext.of(userId)), userTable.getTagline(), oldTagline);
}
@Unlocked
@ResponseStatus(HttpStatus.OK)
@PermissionRequired(perms = NamedPermission.EDIT_OWN_USER_SETTINGS)
@PostMapping("/users/{userId}/settings/resetTagline")
public void resetTagline(@PathVariable long userId) {
UserTable userTable = userService.getUserTable(userId);
if (userTable == null) {
throw new HangarApiException(HttpStatus.NOT_FOUND);
}
String oldTagline = userTable.getTagline();
if (oldTagline == null) {
throw new HangarApiException(HttpStatus.BAD_REQUEST);
}
userTable.setTagline(null);
userService.updateUser(userTable);
userActionLogService.user(LoggedActionType.USER_TAGLINE_CHANGED.with(UserContext.of(userId)), "", oldTagline);
}
@GetMapping("/notifications")
public ResponseEntity<List<HangarNotification>> getUserNotifications() {
return ResponseEntity.ok(notificationService.getUsersNotifications());

View File

@ -1,7 +1,6 @@
package io.papermc.hangar.controller.internal;
import io.papermc.hangar.model.common.NamedPermission;
import io.papermc.hangar.model.common.PermissionType;
import io.papermc.hangar.model.common.ReviewAction;
import io.papermc.hangar.model.common.projects.ReviewState;
import io.papermc.hangar.model.internal.api.requests.versions.ReviewMessage;
@ -28,7 +27,7 @@ import java.util.List;
@Controller
@Secured("ROLE_USER")
@RequestMapping(path = "/api/internal/reviews")
@PermissionRequired(type = PermissionType.GLOBAL, perms = NamedPermission.REVIEWER)
@PermissionRequired(perms = NamedPermission.REVIEWER)
public class ReviewController {
private final ReviewService reviewService;

View File

@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import io.papermc.hangar.model.db.projects.ProjectOwner;
import org.jdbi.v3.core.mapper.PropagateNull;
import org.jdbi.v3.core.mapper.reflect.JdbiConstructor;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
import java.util.List;
@ -69,6 +70,7 @@ public class UserTable extends Table implements ProjectOwner {
this.email = email;
}
@Nullable
public String getTagline() {
return tagline;
}

View File

@ -13,7 +13,7 @@ import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PermissionRequired {
PermissionType type();
PermissionType type() default PermissionType.GLOBAL;
NamedPermission[] perms();
String args() default "{}";
}

View File

@ -60,6 +60,11 @@ public class UserService extends HangarService {
hangarUsersDAO.setNotStarred(projectId, getHangarPrincipal().getUserId());
}
}
public void updateUser(UserTable userTable) {
userDAO.update(userTable);
}
@Nullable
private <T> UserTable getUserTable(@Nullable T identifier, @NotNull Function<T, UserTable> userTableFunction) {
if (identifier == null) {