organization creation

This commit is contained in:
Jake Potrebic 2021-03-21 01:53:22 -07:00
parent 87c2af6a18
commit 5764d43230
No known key found for this signature in database
GPG Key ID: 7C58557EC9C421F8
27 changed files with 477 additions and 37 deletions

View File

@ -15,7 +15,15 @@
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
<v-btn v-if="alwaysEditing || isEdited" color="success" small class="flex-right" :loading="loading.save" :disabled="!isEdited" @click="save">
<v-btn
v-if="(alwaysEditing || isEdited) && !noSaveBtn"
color="success"
small
class="flex-right"
:loading="loading.save"
:disabled="!isEdited"
@click="save"
>
<v-icon left>mdi-check</v-icon>
{{ $t('general.save') }}
</v-btn>
@ -105,7 +113,7 @@
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'nuxt-property-decorator';
import { PropType } from 'vue';
import { Joinable, JoinableMember } from 'hangar-internal';
import { JoinableMember } from 'hangar-internal';
import { PaginatedResult, Role, User } from 'hangar-api';
import { UserAvatar } from '~/components/users';
@ -124,8 +132,8 @@ interface EditableMember {
components: { UserAvatar },
})
export default class MemberList extends Vue {
@Prop({ type: Object as PropType<Joinable>, required: true })
joinable!: Joinable;
@Prop({ type: Array as PropType<JoinableMember[]>, default: () => [] })
members!: JoinableMember[];
@Prop({ type: Boolean, default: false })
alwaysEditing!: boolean;
@ -133,6 +141,12 @@ export default class MemberList extends Vue {
@Prop({ type: Array as PropType<Role[]>, required: true })
roles!: Role[];
@Prop({ type: Function as PropType<(name: User) => boolean>, default: () => (_: User) => true })
searchFilter!: (user: User) => boolean;
@Prop({ type: Boolean, default: false })
noSaveBtn!: boolean;
editing: boolean = false;
editingMembers: EditableMember[] = [];
userSearch: string = '';
@ -153,7 +167,7 @@ export default class MemberList extends Vue {
setupEditing() {
this.editingMembers = [];
this.editingMembers = this.convertMembers(this.joinable.members);
this.editingMembers = this.convertMembers(this.members);
}
convertMembers(jms: JoinableMember[]): EditableMember[] {
@ -174,12 +188,12 @@ export default class MemberList extends Vue {
(em) =>
em.toDelete ||
(em.new && em.roleId) ||
(em.editing && !em.new && em.roleId !== this.joinable.members.find((jm) => jm.user.name === em.name)!.role.role.roleId)
(em.editing && !em.new && em.roleId !== this.members.find((jm) => jm.user.name === em.name)!.role.role.roleId)
);
}
stopEditing(member: EditableMember) {
const originalMember = this.joinable.members.find((jm) => jm.user.name === member.name)!;
const originalMember = this.members.find((jm) => jm.user.name === member.name)!;
member.roleTitle = originalMember.role.role.title;
member.roleId = originalMember.role.role.roleId;
member.editing = false;
@ -240,7 +254,7 @@ export default class MemberList extends Vue {
offset: 0,
})
.then((users) => {
this.users = users.result.filter((u) => this.editingMembers.findIndex((em) => em.name === u.name) === -1);
this.users = users.result.filter(this.searchFilter).filter((u) => this.editingMembers.findIndex((em) => em.name === u.name) === -1);
})
.catch(this.$util.handleRequestError)
.finally(() => {

View File

@ -380,6 +380,15 @@ const msgs: LocaleMessageObject = {
title: 'Create a new Organization',
text: 'Organizations allow you group users provide closer collaboration between them within your projects on Hangar.',
name: 'Organization Name',
error: {
duplicateName: 'An organization/user with that name already exists',
invalidName: 'Invalid organization name',
tooManyOrgs: 'You can only create a maximum of {0} organizations',
notEnabled: 'Organizations are not enabled!',
jsonError: 'Error parsing the JSON response from HangarAuth',
hangarAuthValidationError: 'Validation Error: {0}',
unknownError: 'Unknown error while creating organization',
},
},
},
form: {
@ -426,6 +435,9 @@ const msgs: LocaleMessageObject = {
invite: 'You have been invited to join the group {0} on the project {1}',
newVersion: 'A new version has been released for {0}: {1}',
},
organization: {
invite: 'You have been invited to join the group {0} in the organization {1}',
},
},
visibility: {
notice: {

View File

@ -27,7 +27,7 @@
</v-list-item-action>
</v-list-item>
</v-list>
<div v-else>
<div v-else class="red--text text--lighten-2">
{{ $t(`notifications.empty.${filters.notification}`) }}
</div>
</v-col>
@ -75,7 +75,7 @@
</v-list-item-action>
</v-list-item>
</v-list>
<div v-else>
<div v-else class="red--text text--lighten-2">
{{ $t('notifications.empty.invites') }}
</div>
</v-col>

View File

@ -2,25 +2,121 @@
<v-col cols="12" md="8" offset-md="2">
<v-card>
<v-card-title v-text="$t('organization.new.title')" />
<v-card-subtitle>{{ $t('organization.new.text') }}</v-card-subtitle>
<!--TODO error message if already at max orgs-->
<v-card-text>
{{ $t('organization.new.text') }}
<v-text-field type="text" :label="$t('organization.new.name')"></v-text-field>
<v-divider />
<!--<UserSelectionForm />-->
<v-form v-model="validForm">
<v-text-field
v-model="form.name"
class="mt-2"
filled
:loading="validateLoading"
:label="$t('organization.new.name')"
:rules="[
$util.$vc.require($t('organization.new.name')),
$util.$vc.regex($t('organization.new.name'), validations.org.regex),
$util.$vc.minLength(validations.org.min),
$util.$vc.maxLength(validations.org.max),
]"
:error-messages="nameErrorMessages"
/>
<v-divider />
<MemberList ref="memberList" class="mt-7 elevation-5" no-save-btn :roles="roles" always-editing :search-filter="searchFilter" />
</v-form>
</v-card-text>
<v-card-actions>
<v-btn>{{ $t('form.memberList.create') }}</v-btn>
<v-card-actions class="justify-end">
<v-btn color="success" :disabled="!canCreate" :loading="loading" @click="create">
<v-icon left>mdi-check</v-icon>
{{ $t('form.memberList.create') }}
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</template>
<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator';
import { Component, Watch } from 'nuxt-property-decorator';
import { TranslateResult } from 'vue-i18n';
import { Context } from '@nuxt/types';
import { Role, User } from 'hangar-api';
import { HangarForm } from '~/components/mixins';
import MemberList from '~/components/projects/MemberList.vue';
// TODO implement OrganizationsNewPage
@Component
export default class OrganizationsNewPage extends Vue {}
@Component({
components: { MemberList },
})
export default class OrganizationsNewPage extends HangarForm {
head() {
return {
title: this.$t('organization.new.title'),
};
}
roles!: Role[];
nameErrorMessages: TranslateResult[] = [];
validateLoading = false;
form = {
name: '',
};
$refs!: {
memberList: MemberList;
};
searchFilter(user: User) {
return user.name !== this.currentUser.name;
}
get canCreate() {
return (
this.validForm &&
!this.validateLoading &&
(this.$refs.memberList.isEdited || (this.$refs.memberList.editedMembers.length === 0 && this.$refs.memberList.editingMembers.length === 0))
);
}
create() {
this.loading = true;
this.$api
.requestInternal('organizations/create', true, 'post', {
name: this.form.name,
members: this.$refs.memberList.editedMembers,
})
.then(() => {
this.$router.push({
name: 'user',
params: {
user: this.form.name,
},
});
})
.catch(this.$util.handleRequestError)
.finally(() => {
this.loading = false;
});
}
@Watch('form.name')
checkName(val: string) {
if (!val) return true;
this.validateLoading = true;
this.nameErrorMessages = [];
this.$api
.requestInternal('organizations/validate', false, 'get', { name: val })
.catch(() => {
this.nameErrorMessages.push(this.$t('organization.new.error.duplicateName'));
})
.finally(() => {
this.validateLoading = false;
});
}
async asyncData({ $api, $util }: Context) {
const orgRoles = await $api.requestInternal('data/orgRoles', false).catch($util.handlePageRequestError);
return { roles: orgRoles };
}
}
</script>
<style lang="scss" scoped></style>

View File

@ -25,6 +25,7 @@ export interface RootState {
maxPageCount: number;
maxChannelCount: number;
};
org: Validation;
userTagline: Validation;
version: Validation;
maxOrgCount: number;

View File

@ -3,12 +3,19 @@ package io.papermc.hangar.config.hangar;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.function.Predicate;
import java.util.regex.Pattern;
@Component
@ConfigurationProperties(prefix = "hangar.orgs")
public class OrganizationsConfig {
private boolean enabled = true;
private String dummyEmailDomain = "org.papermc.io";
private int createLimit = 5;
private int minNameLen = 3;
private int maxNameLen = 24;
private String nameRegex = "[a-zA-Z0-9-_]*";
private final Predicate<String> namePredicate = Pattern.compile(nameRegex).asMatchPredicate();
public boolean isEnabled() {
return enabled;
@ -33,4 +40,32 @@ public class OrganizationsConfig {
public void setCreateLimit(int createLimit) {
this.createLimit = createLimit;
}
public int getMinNameLen() {
return minNameLen;
}
public void setMinNameLen(int minNameLen) {
this.minNameLen = minNameLen;
}
public String getNameRegex() {
return nameRegex;
}
public void setNameRegex(String nameRegex) {
this.nameRegex = nameRegex;
}
public int getMaxNameLen() {
return maxNameLen;
}
public void setMaxNameLen(int maxNameLen) {
this.maxNameLen = maxNameLen;
}
public boolean testName(String name) {
return namePredicate.test(name);
}
}

View File

@ -24,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import javax.validation.constraints.NotEmpty;
import java.util.Map;
@Controller
@ -55,8 +56,8 @@ public class SSOSyncController {
@ApiResponse(code = 200, message = "Ok"),
@ApiResponse(code = 401, message = "Sent if the signature or API key missing or invalid.")
})
@PostMapping(value = "/sync_sso")
public ResponseEntity<MultiValueMap<String, String>> syncSso(@RequestParam String sso, @RequestParam String sig, @RequestParam String apiKey) {
@PostMapping(value = "/sync_sso", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<MultiValueMap<String, String>> syncSso(@RequestParam @NotEmpty String sso, @RequestParam @NotEmpty String sig, @RequestParam("api_key") @NotEmpty String apiKey) {
if (!apiKey.equals(ssoConfig.getApiKey())) {
log.warn("SSO sync failed: bad API key (" + apiKey + " provided, " + ssoConfig.getApiKey() + " expected)");
throw new HangarApiException(HttpStatus.BAD_REQUEST, "SSO sync failed: bad API key (" + apiKey + " provided, " + ssoConfig.getApiKey() + " expected)");

View File

@ -15,6 +15,7 @@ import io.papermc.hangar.model.common.NamedPermission;
import io.papermc.hangar.model.common.Platform;
import io.papermc.hangar.model.common.projects.Category;
import io.papermc.hangar.model.common.projects.FlagReason;
import io.papermc.hangar.model.common.roles.OrganizationRole;
import io.papermc.hangar.model.common.roles.ProjectRole;
import io.papermc.hangar.security.annotations.Anyone;
import io.papermc.hangar.service.internal.projects.PlatformService;
@ -130,6 +131,11 @@ public class BackendDataController {
return ResponseEntity.ok(ProjectRole.getAssignableRoles());
}
@GetMapping("/orgRoles")
public ResponseEntity<List<OrganizationRole>> getAssignableOrganizationRoles() {
return ResponseEntity.ok(OrganizationRole.getAssignableRoles());
}
@GetMapping("/validations")
public ResponseEntity<ObjectNode> getValidations() {
ObjectNode validations = mapper.createObjectNode();
@ -145,6 +151,7 @@ public class BackendDataController {
validations.set("project", projectValidations);
validations.set("userTagline", mapper.valueToTree(new Validation(null, config.user.getMaxTaglineLen(), null)));
validations.set("version", mapper.valueToTree(new Validation(config.projects.getVersionNameRegex(), null, null)));
validations.set("org", mapper.valueToTree(new Validation(config.org.getNameRegex(), config.org.getMaxNameLen(), config.org.getMinNameLen())));
validations.put("maxOrgCount", config.org.getCreateLimit());
validations.put("urlRegex", config.getUrlRegex());
return ResponseEntity.ok(validations);

View File

@ -0,0 +1,51 @@
package io.papermc.hangar.controller.internal;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.model.internal.api.requests.CreateOrganizationForm;
import io.papermc.hangar.security.annotations.Anyone;
import io.papermc.hangar.security.annotations.unlocked.Unlocked;
import io.papermc.hangar.service.internal.organizations.OrganizationFactory;
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;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import javax.validation.Valid;
@Controller
@RequestMapping("/api/internal/organizations")
public class OrganizationController {
private final UserService userService;
private final OrganizationFactory organizationFactory;
@Autowired
public OrganizationController(UserService userService, OrganizationFactory organizationFactory) {
this.userService = userService;
this.organizationFactory = organizationFactory;
}
@Anyone
@ResponseStatus(HttpStatus.OK)
@GetMapping("/validate")
public void validateName(@RequestParam String name) {
if (userService.getUserTable(name) != null) {
throw new HangarApiException(HttpStatus.BAD_REQUEST);
}
}
@Unlocked
@ResponseStatus(HttpStatus.OK)
@PostMapping(path = "/create", consumes = MediaType.APPLICATION_JSON_VALUE)
public void create(@Valid @RequestBody CreateOrganizationForm createOrganizationForm) {
System.out.println(createOrganizationForm);
organizationFactory.createOrganization(createOrganizationForm.getName(), createOrganizationForm.getNewMembers());
}
}

View File

@ -16,7 +16,7 @@ import io.papermc.hangar.security.annotations.permission.PermissionRequired;
import io.papermc.hangar.security.annotations.unlocked.Unlocked;
import io.papermc.hangar.security.annotations.visibility.VisibilityRequired;
import io.papermc.hangar.security.annotations.visibility.VisibilityRequired.Type;
import io.papermc.hangar.service.internal.OrganizationService;
import io.papermc.hangar.service.internal.organizations.OrganizationService;
import io.papermc.hangar.service.internal.projects.ProjectFactory;
import io.papermc.hangar.service.internal.projects.ProjectService;
import io.papermc.hangar.service.internal.users.UserService;

View File

@ -43,6 +43,13 @@ public class Validations {
return false;
}
public boolean min(String value, int min) {
if (value != null) {
return value.length() >= min;
}
return false;
}
private boolean isEmpty(String value) {
return value == null || value.isBlank() || value.isEmpty();
}

View File

@ -49,7 +49,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Controller
@Controller("oldOrganizationController")
public class OrganizationController extends HangarController {
private static final String STATUS_DECLINE = "decline";

View File

@ -36,4 +36,7 @@ public interface OrganizationDAO {
" WHERE ot.user_id = :userId" +
" AND (ot.permission & :permission::bit(64)) = ot.permission")
List<OrganizationTable> getOrganizationsWithPermission(long userId, Permission permission);
@SqlQuery("SELECT * FROM organizations WHERE owner_id = :ownerId")
List<OrganizationTable> getOrganizationsOwnedBy(long ownerId);
}

View File

@ -2,6 +2,7 @@ package io.papermc.hangar.db.dao.internal.table.members;
import io.papermc.hangar.model.db.members.OrganizationMemberTable;
import org.jdbi.v3.sqlobject.config.RegisterConstructorMapper;
import org.jdbi.v3.sqlobject.customizer.BindBean;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
@ -14,7 +15,7 @@ public interface OrganizationMembersDAO extends MembersDAO<OrganizationMemberTab
@Override
@GetGeneratedKeys
@SqlUpdate("INSERT INTO organization_members (user_id, organization_id) VALUES (:userId, :organizationId)")
OrganizationMemberTable insert(OrganizationMemberTable table);
OrganizationMemberTable insert(@BindBean OrganizationMemberTable table);
@Override
@SqlQuery("SELECT * FROM organization_members WHERE organization_id = :organizationId AND user_id = :userId")

View File

@ -1,12 +1,19 @@
package io.papermc.hangar.db.dao.internal.table.roles;
import io.papermc.hangar.db.mappers.RoleMapperFactory;
import io.papermc.hangar.model.db.roles.OrganizationRoleTable;
import org.jdbi.v3.sqlobject.config.RegisterColumnMapperFactory;
import org.jdbi.v3.sqlobject.config.RegisterConstructorMapper;
import org.jdbi.v3.sqlobject.customizer.BindBean;
import org.jdbi.v3.sqlobject.customizer.Timestamped;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import org.springframework.stereotype.Repository;
@Repository
@RegisterConstructorMapper(OrganizationRoleTable.class)
@RegisterColumnMapperFactory(RoleMapperFactory.class)
public interface OrganizationRolesDAO extends IRolesDAO<OrganizationRoleTable> {
@Override

View File

@ -33,7 +33,9 @@ public enum GlobalRole implements Role<GlobalRoleTable> {
QUARTZ_DONOR("Quartz_Donor",15, Permission.None, "Quartz Donor", Color.QUARTZ, 4),
IRON_DONOR("Iron_Donor",16, Permission.None, "Iron Donor", Color.SILVER, 3),
GOLD_DONOR("Gold_Donor",17, Permission.None, "Gold Donor", Color.GOLD, 2),
DIAMOND_DONOR("Diamond_Donor",18, Permission.None, "Diamond Donor", Color.LIGHTBLUE, 1);
DIAMOND_DONOR("Diamond_Donor",18, Permission.None, "Diamond Donor", Color.LIGHTBLUE, 1),
ORGANIZATION("Organization", 23, OrganizationRole.ORGANIZATION_OWNER.getPermissions(), "Organization", Color.PURPLE);
private final String value;
private final long roleId;
@ -103,7 +105,7 @@ public enum GlobalRole implements Role<GlobalRoleTable> {
@NotNull
@Override
public GlobalRoleTable create(@Nullable Long principalId, long userId, boolean isAccepted) {
public GlobalRoleTable create(@Nullable Long __ignoreThis, long userId, boolean __ignoreThisToo) {
return new GlobalRoleTable(userId, this);
}
}

View File

@ -10,6 +10,10 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.postgresql.shaded.com.ongres.scram.common.util.Preconditions;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum OrganizationRole implements Role<OrganizationRoleTable> {
@ -17,8 +21,7 @@ public enum OrganizationRole implements Role<OrganizationRoleTable> {
ORGANIZATION_EDITOR("Organization_Editor", 27, ProjectRole.PROJECT_EDITOR.getPermissions().add(ORGANIZATION_SUPPORT.permissions), "Editor", Color.TRANSPARENT),
ORGANIZATION_DEVELOPER("Organization_Developer", 26, Permission.CreateProject.add(Permission.EditProjectSettings).add(ProjectRole.PROJECT_DEVELOPER.getPermissions()).add(ORGANIZATION_EDITOR.permissions), "Developer", Color.TRANSPARENT),
ORGANIZATION_ADMIN("Organization_Admin", 25, Permission.EditApiKeys.add(Permission.ManageProjectMembers).add(Permission.EditOwnUserSettings).add(Permission.DeleteProject).add(Permission.DeleteVersion).add(ORGANIZATION_DEVELOPER.permissions), "Admin", Color.TRANSPARENT),
ORGANIZATION_OWNER("Organization_Owner", 24, Permission.IsOrganizationOwner.add(ProjectRole.PROJECT_OWNER.getPermissions()).add(ORGANIZATION_ADMIN.permissions), "Owner", Color.PURPLE, false),
ORGANIZATION("Organization", 23, ORGANIZATION_OWNER.permissions, "Organization", Color.PURPLE, false);
ORGANIZATION_OWNER("Organization_Owner", 24, Permission.IsOrganizationOwner.add(ProjectRole.PROJECT_OWNER.getPermissions()).add(ORGANIZATION_ADMIN.permissions), "Owner", Color.PURPLE, false);
private final String value;
private final long roleId;
@ -28,8 +31,10 @@ public enum OrganizationRole implements Role<OrganizationRoleTable> {
private final boolean isAssignable;
private final static OrganizationRole[] VALUES = values();
private final static List<OrganizationRole> ASSIGNABLE_ROLES = Arrays.stream(VALUES).filter(OrganizationRole::isAssignable).collect(Collectors.toList());
public static OrganizationRole[] getValues() { return VALUES; }
public static List<OrganizationRole> getAssignableRoles() { return ASSIGNABLE_ROLES; }
OrganizationRole(String value, long roleId, Permission permissions, String title, Color color) {
this(value, roleId, permissions, title, color, true);
@ -93,8 +98,8 @@ public enum OrganizationRole implements Role<OrganizationRoleTable> {
}
@Override
public @NotNull OrganizationRoleTable create(@Nullable Long principalId, long userId, boolean isAccepted) {
Preconditions.checkNotNull(principalId, "organization id");
return new OrganizationRoleTable(userId, this, isAccepted, principalId);
public @NotNull OrganizationRoleTable create(Long organizationId, long userId, boolean isAccepted) {
Preconditions.checkNotNull(organizationId, "organization id");
return new OrganizationRoleTable(userId, this, isAccepted, organizationId);
}
}

View File

@ -104,8 +104,8 @@ public enum ProjectRole implements Role<ProjectRoleTable> {
@NotNull
@Override
public ProjectRoleTable create(@Nullable Long principalId, long userId, boolean isAccepted) {
Preconditions.checkNotNull(principalId, "project id");
return new ProjectRoleTable(userId, this, isAccepted, principalId);
public ProjectRoleTable create(Long projectId, long userId, boolean isAccepted) {
Preconditions.checkNotNull(projectId, "project id");
return new ProjectRoleTable(userId, this, isAccepted, projectId);
}
}

View File

@ -2,6 +2,7 @@ package io.papermc.hangar.model.db;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.papermc.hangar.model.db.projects.ProjectOwner;
import io.papermc.hangar.model.internal.sso.AuthUser;
import org.jdbi.v3.core.mapper.PropagateNull;
import org.jdbi.v3.core.mapper.reflect.JdbiConstructor;
import org.jetbrains.annotations.Nullable;
@ -43,6 +44,15 @@ public class UserTable extends Table implements ProjectOwner {
this.language = language;
}
// For use when creating orgs (when fake user is enabled)
public UserTable(AuthUser authUser) {
super(authUser.getId());
this.name = authUser.getUserName();
this.email = authUser.getEmail();
this.readPrompts = List.of();
this.language = authUser.getLang().toLanguageTag();
}
@JsonIgnore
public String getFullName() {
return fullName;

View File

@ -0,0 +1,30 @@
package io.papermc.hangar.model.internal.api.requests;
import io.papermc.hangar.controller.validations.Validate;
import io.papermc.hangar.model.common.roles.OrganizationRole;
import java.util.List;
public class CreateOrganizationForm extends EditMembersForm<OrganizationRole> {
@Validate(SpEL = "@validate.regex(#root, @hangarConfig.org.nameRegex)", message = "organizations.new.error.invalidName")
@Validate(SpEL = "@validate.max(#root, @hangarConfig.org.maxNameLen)", message = "organizations.new.error.invalidName")
@Validate(SpEL = "@validate.min(#root, @hangarConfig.org.minNameLen)", message = "organizations.new.error.invalidName")
private final String name;
public CreateOrganizationForm(List<Member<OrganizationRole>> members, String name) {
super(members);
this.name = name;
}
public String getName() {
return name;
}
@Override
public String toString() {
return "CreateOrganizationForm{" +
"name='" + name + '\'' +
"} " + super.toString();
}
}

View File

@ -33,6 +33,15 @@ public class AuthUser {
}
}
public AuthUser(String userName, String email) {
this.id = -100;
this.userName = userName;
this.email = email;
this.avatarUrl = "";
this.lang = Locale.ENGLISH;
this.globalRoles = new ArrayList<>();
}
public long getId() {
return id;
}

View File

@ -14,6 +14,5 @@ import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAnyRole('ANONYMOUS', 'USER')")
// TODO need a better name for this annotation
public @interface Anyone {
}

View File

@ -0,0 +1,14 @@
package io.papermc.hangar.security.annotations;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('USER')")
public @interface LoggedIn {
}

View File

@ -0,0 +1,126 @@
package io.papermc.hangar.service.internal.organizations;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.papermc.hangar.db.customtypes.LoggedActionType;
import io.papermc.hangar.db.customtypes.LoggedActionType.OrganizationContext;
import io.papermc.hangar.db.dao.HangarDao;
import io.papermc.hangar.db.dao.internal.table.OrganizationDAO;
import io.papermc.hangar.db.dao.internal.table.UserDAO;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.exceptions.MultiHangarApiException;
import io.papermc.hangar.model.common.roles.GlobalRole;
import io.papermc.hangar.model.common.roles.OrganizationRole;
import io.papermc.hangar.model.db.OrganizationTable;
import io.papermc.hangar.model.db.UserTable;
import io.papermc.hangar.model.internal.api.requests.EditMembersForm.Member;
import io.papermc.hangar.model.internal.sso.AuthUser;
import io.papermc.hangar.service.HangarService;
import io.papermc.hangar.service.internal.roles.GlobalRoleService;
import io.papermc.hangar.service.internal.roles.MemberService.OrganizationMemberService;
import io.papermc.hangar.service.internal.users.NotificationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException.UnprocessableEntity;
import org.springframework.web.client.RestTemplate;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@Service
public class OrganizationFactory extends HangarService {
private final UserDAO userDAO;
private final OrganizationDAO organizationDAO;
private final OrganizationService organizationService;
private final OrganizationMemberService organizationMemberService;
private final GlobalRoleService globalRoleService;
private final NotificationService notificationService;
private final ObjectMapper mapper;
private final RestTemplate restTemplate;
@Autowired
public OrganizationFactory(HangarDao<UserDAO> userDAO, HangarDao<OrganizationDAO> organizationDAO, OrganizationService organizationService, OrganizationMemberService organizationMemberService, GlobalRoleService globalRoleService, NotificationService notificationService, ObjectMapper mapper, RestTemplate restTemplate) {
this.userDAO = userDAO.get();
this.organizationDAO = organizationDAO.get();
this.organizationService = organizationService;
this.organizationMemberService = organizationMemberService;
this.globalRoleService = globalRoleService;
this.notificationService = notificationService;
this.mapper = mapper;
this.restTemplate = restTemplate;
}
public void createOrganization(String name, List<Member<OrganizationRole>> members) {
if (!config.org.isEnabled()) {
throw new HangarApiException(HttpStatus.BAD_REQUEST, "organization.new.error.notEnabled");
}
if (organizationService.getOrganizationsOwnedBy(getHangarPrincipal().getId()).size() >= config.org.getCreateLimit()) {
throw new HangarApiException(HttpStatus.BAD_REQUEST, "organization.new.error.tooManyOrgs", config.org.getCreateLimit());
}
String dummyEmail = name.replaceAll("[^a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]", "") + '@' + config.org.getDummyEmailDomain();
AuthUser authOrganizationUser;
if (!config.fakeUser.isEnabled()) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("api-key", config.sso.getApiKey());
map.add("username", name);
map.add("email", dummyEmail);
map.add("verified", Boolean.TRUE.toString());
map.add("dummy", Boolean.TRUE.toString());
try {
authOrganizationUser = mapper.treeToValue(restTemplate.postForObject(config.security.api.getUrl() + "/api/users", new HttpEntity<>(map, headers), ObjectNode.class), AuthUser.class);
} catch (UnprocessableEntity e) {
try {
ObjectNode objectNode = mapper.readValue(e.getResponseBodyAsByteArray(), ObjectNode.class);
List<HangarApiException> errors = new ArrayList<>();
for (JsonNode jsonNode : objectNode.get("error")) {
errors.add(new HangarApiException("organization.new.error.hangarAuthValidationError", jsonNode.asText()));
}
if (!errors.isEmpty()) {
throw new MultiHangarApiException(errors);
}
} catch (IOException __) {
throw new HangarApiException(HttpStatus.INTERNAL_SERVER_ERROR, "organization.new.error.unknownError");
}
throw new HangarApiException(HttpStatus.INTERNAL_SERVER_ERROR, "organization.new.error.unknownError");
} catch (JsonProcessingException e) {
throw new HangarApiException(HttpStatus.INTERNAL_SERVER_ERROR, "organization.new.error.jsonError");
}
} else {
authOrganizationUser = new AuthUser(name, dummyEmail);
userDAO.insert(new UserTable(authOrganizationUser));
}
// Just a note, the /api/sync_sso creates the org user here, so it will already be created when the above response is returned
UserTable userTable = userDAO.getUserTable(authOrganizationUser.getId());
OrganizationTable organizationTable = organizationDAO.insert(new OrganizationTable(authOrganizationUser.getId(), name, getHangarPrincipal().getId(), userTable.getId()));
globalRoleService.addRole(GlobalRole.ORGANIZATION.create(null, userTable.getId(), false));
organizationMemberService.addMember(organizationTable.getId(), OrganizationRole.ORGANIZATION_OWNER.create(organizationTable.getId(), getHangarPrincipal().getId(), true));
List<String> newLogState = new ArrayList<>();
members.forEach(member -> {
UserTable memberTable = userDAO.getUserTable(member.getName());
if (memberTable == null) {
// TODO errors
return;
}
newLogState.add(member.getName() + ": " + member.getRole().getTitle());
organizationMemberService.addMember(organizationTable.getId(), member.getRole().create(organizationTable.getId(), memberTable.getId(), false));
notificationService.notifyNewOrganizationMember(member, memberTable.getId(), organizationTable);
});
userActionLogService.organization(LoggedActionType.ORG_MEMBERS_ADDED.with(OrganizationContext.of(organizationTable.getId())), String.join("<br>", newLogState), "<i>No Members</i>");
}
}

View File

@ -1,4 +1,4 @@
package io.papermc.hangar.service.internal;
package io.papermc.hangar.service.internal.organizations;
import io.papermc.hangar.db.dao.HangarDao;
import io.papermc.hangar.db.dao.internal.table.OrganizationDAO;
@ -27,4 +27,8 @@ public class OrganizationService extends HangarService {
public List<OrganizationTable> getOrganizationTablesWithPermission(long userId, Permission permission) {
return organizationDAO.getOrganizationsWithPermission(userId, permission);
}
public List<OrganizationTable> getOrganizationsOwnedBy(long ownerId) {
return organizationDAO.getOrganizationsOwnedBy(ownerId);
}
}

View File

@ -28,7 +28,7 @@ import io.papermc.hangar.model.internal.user.JoinableMember;
import io.papermc.hangar.service.HangarService;
import io.papermc.hangar.service.PermissionService;
import io.papermc.hangar.service.VisibilityService.ProjectVisibilityService;
import io.papermc.hangar.service.internal.OrganizationService;
import io.papermc.hangar.service.internal.organizations.OrganizationService;
import io.papermc.hangar.service.internal.roles.MemberService.ProjectMemberService;
import io.papermc.hangar.service.internal.roles.RoleService.ProjectRoleService;
import io.papermc.hangar.service.internal.uploads.ProjectFiles;

View File

@ -5,8 +5,10 @@ import io.papermc.hangar.db.dao.internal.HangarNotificationsDAO;
import io.papermc.hangar.db.dao.internal.table.NotificationsDAO;
import io.papermc.hangar.db.dao.internal.table.projects.ProjectsDAO;
import io.papermc.hangar.model.common.Permission;
import io.papermc.hangar.model.common.roles.OrganizationRole;
import io.papermc.hangar.model.common.roles.ProjectRole;
import io.papermc.hangar.model.db.NotificationTable;
import io.papermc.hangar.model.db.OrganizationTable;
import io.papermc.hangar.model.db.UserTable;
import io.papermc.hangar.model.db.projects.ProjectTable;
import io.papermc.hangar.model.db.versions.ProjectVersionTable;
@ -77,4 +79,8 @@ public class NotificationService extends HangarService {
public void notifyNewProjectMember(Member<ProjectRole> member, long userId, ProjectTable projectTable) {
notificationsDAO.insert(new NotificationTable(userId, NotificationType.PROJECT_INVITE, null, projectTable.getId(), new String[]{"notifications.project.invite", member.getRole().getTitle(), projectTable.getName()}));
}
public void notifyNewOrganizationMember(Member<OrganizationRole> member, long userId, OrganizationTable organizationTable) {
notificationsDAO.insert(new NotificationTable(userId, NotificationType.ORGANIZATION_INVITE, null, organizationTable.getId(), new String[]{"notifications.organization.invite", member.getRole().getTitle(), organizationTable.getName()}));
}
}