student life social platform
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'gwenn/fixcancreategroup' into 'main'

fix(groups/permissions): fix canCreateGroup

See merge request churros/churros!652

+28 -561
+5
.changeset/early-lamps-rescue.md
··· 1 + --- 2 + '@churros/api': major 3 + --- 4 + 5 + Remove deprecated Mutation.upsertGroup
+5
.changeset/pink-spies-cough.md
··· 1 + --- 2 + '@churros/api': patch 3 + --- 4 + 5 + canCreateGroup would always return false
+4
CHANGELOG.md
··· 11 11 12 12 ## [Unreleased] 13 13 14 + ### Corrections 15 + 16 + - Un bug empêchait les respos clubs ou admins d'AE de créer des groupes 17 + 14 18 ## [4.8.2] - 2025-02-15 15 19 16 20 ## [4.8.1] - 2025-02-11
+5 -2
packages/api/src/modules/groups/resolvers/mutation.create-group.ts
··· 25 25 defaultValue: 'Group', 26 26 }), 27 27 }, 28 - async authScopes(_, { studentAssociation, type }, { user }) { 28 + async authScopes(_, { studentAssociation: studentAssociationUid, type }, { user }) { 29 + const studentAssociation = await prisma.studentAssociation.findUniqueOrThrow({ 30 + where: { uid: studentAssociationUid }, 31 + }); 29 32 return canCreateGroup(user, { 30 - studentAssociationUid: studentAssociation, 33 + studentAssociationId: studentAssociation.id, 31 34 type, 32 35 }); 33 36 },
-247
packages/api/src/modules/groups/resolvers/mutation.upsert-group.ts
··· 1 - import { builder, freeUidValidator, graphinx, log, prisma, purgeSessionsUser } from '#lib'; 2 - import { UIDScalar } from '#modules/global'; 3 - import { getDescendants, hasCycle } from 'arborist'; 4 - import { GraphQLError } from 'graphql'; 5 - import { ZodError } from 'zod'; 6 - import { GroupEnumType, GroupType } from '../index.js'; 7 - import { canCreateGroup, canEditGroup } from '../utils/index.js'; 8 - 9 - /* 10 - TODO split into: 11 - - upsertGroup 12 - - changeGroupStudentAssociation (can also remove) 13 - - changeParentGroup (can also remove) 14 - 15 - This would prevent confusion around null (ie removing) vs undefined (ie not changing), the distinction does not exist in GraphQL 16 - 17 - And would also make the code for canEditGroup more manageable 18 - 19 - This would also allow us to (maybe?) change the contact email automatically when the student association changes (unless it is already set to sth different than the old student association's email) 20 - */ 21 - 22 - const UpsertGroupInput = builder.inputType('UpsertGroupInput', { 23 - ...graphinx('groups'), 24 - fields: (t) => ({ 25 - uid: t.field({ 26 - required: false, 27 - type: UIDScalar, 28 - validate: freeUidValidator, 29 - description: 30 - "Ne sert qu'à la création du groupe. Il est impossible de modifier un uid existant", 31 - }), 32 - type: t.field({ type: GroupEnumType }), 33 - parent: t.field({ type: UIDScalar, required: false }), 34 - school: t.field({ 35 - type: UIDScalar, 36 - required: false, 37 - deprecationReason: 38 - "N'a aucun effet, les groupes ne peuvent plus être reliés à des écoles directement", 39 - }), 40 - studentAssociation: t.field({ type: UIDScalar, required: false }), 41 - name: t.string({ validate: { maxLength: 255 } }), 42 - color: t.string({ required: false, validate: { regex: /#[\dA-Fa-f]{6}/ } }), 43 - address: t.string({ validate: { maxLength: 255 } }), 44 - description: t.string({ validate: { maxLength: 255 } }), 45 - website: t.string({ validate: { maxLength: 255 } }), 46 - email: t.string({ validate: { email: true }, required: false }), 47 - mailingList: t.string({ validate: { email: true }, required: false }), 48 - longDescription: t.string(), 49 - selfJoinable: t.boolean(), 50 - related: t.field({ type: ['String'] }), 51 - }), 52 - }); 53 - 54 - /** Upserts a group. */ 55 - builder.mutationField('upsertGroup', (t) => 56 - t.prismaField({ 57 - deprecationReason: 58 - 'Mutation séparée en plusieurs mutations plus spécifiques. Voir la documentation du module groups', 59 - type: GroupType, 60 - errors: { types: [ZodError, Error] }, 61 - args: { 62 - uid: t.arg({ type: UIDScalar, required: false }), 63 - input: t.arg({ type: UpsertGroupInput }), 64 - }, 65 - validate: [ 66 - [ 67 - ({ uid, input }) => !(uid && input.uid), 68 - { message: "Impossible de modifier l'@ d'un groupe existant" }, 69 - ], 70 - [ 71 - ({ uid, input }) => !(!uid && !input.uid), 72 - { 73 - message: 74 - 'Use uid to choose which group to update or input.uid to create a new group with that uid', 75 - }, 76 - ], 77 - ], 78 - async authScopes(_, { uid, input }, { user }) { 79 - if (!user) return false; 80 - const creating = !uid; 81 - if (creating) return canCreateGroup(user, input); 82 - 83 - const group = await prisma.group.findUniqueOrThrow({ 84 - where: { uid }, 85 - include: canEditGroup.prismaIncludes, 86 - }); 87 - return canEditGroup(user, group); 88 - }, 89 - // eslint-disable-next-line complexity 90 - async resolve( 91 - query, 92 - _, 93 - { 94 - uid: oldUid, 95 - input: { 96 - selfJoinable, 97 - uid: newUid, 98 - type, 99 - parent: parentUid, 100 - name, 101 - color, 102 - address, 103 - description, 104 - website, 105 - studentAssociation: studentAssociationUid, 106 - email, 107 - mailingList, 108 - longDescription, 109 - related, 110 - }, 111 - }, 112 - { user }, 113 - ) { 114 - if (!user) throw new GraphQLError("Vous n'êtes pas connecté·e"); 115 - if (!studentAssociationUid) throw new GraphQLError("Il faut préciser l'AE de rattachement"); 116 - 117 - // --- First, we update the group's children's familyId according to the new parent of this group. --- 118 - // We have 2 possible cases for updating the parent: either it is: 119 - // - null (or set to ''): the group does not have a parent anymore; 120 - // In that case, the root (set by familyId) is the group itself. 121 - // We don't need to change the root's children 122 - // - an id: the group's parent is changed to the group with that ID. 123 - // In that case, the root is changed to the root of the new parent. 124 - // - if we are creating the group, we don't need to change its children since it has none 125 - // 126 - let familyId; 127 - const oldGroup = await prisma.group.findUnique({ where: { uid: oldUid ?? '' } }); 128 - if (parentUid === null || parentUid === undefined || parentUid === '') { 129 - // First case (null): the group does not have a parent anymore. 130 - // Set both the parent and the root to the group itself. 131 - // eslint-disable-next-line unicorn/no-null 132 - parentUid = null; 133 - // eslint-disable-next-line unicorn/no-null 134 - familyId = oldGroup?.id ?? null; 135 - } else { 136 - // Third case (number): the group's parent is changed to the group with that ID. 137 - const newParent = await prisma.group.findUnique({ where: { uid: parentUid } }); 138 - if (!newParent) throw new GraphQLError('uid de groupe parent invalide'); 139 - familyId = newParent.familyId ?? newParent.id; 140 - // Update all descendants' familyId to the new parent's familyId 141 - // Or when creating (i.e. oldGroup is undefined), just check for cycles 142 - const allGroups = await prisma.group.findMany({}); 143 - if (oldGroup) { 144 - if ( 145 - hasCycle( 146 - allGroups.map((g) => 147 - g.id === oldGroup.id ? { ...oldGroup, parentId: newParent.id } : g, 148 - ), 149 - ) 150 - ) 151 - throw new GraphQLError('La modification créerait un cycle dans les groupes'); 152 - 153 - const descendants = getDescendants(allGroups, oldGroup.id); 154 - await prisma.group.updateMany({ 155 - where: { id: { in: descendants.map((g) => g.id) } }, 156 - data: { 157 - familyId, 158 - }, 159 - }); 160 - } else if (newParent.id && hasCycle([{ parentId: newParent.id, id: '' }, ...allGroups])) { 161 - throw new GraphQLError("Can't create a cycle"); 162 - } 163 - } 164 - 165 - if (parentUid === oldGroup?.uid) throw new GraphQLError('Group cannot be its own parent'); 166 - 167 - if (oldGroup) { 168 - const oldStudentAssociation = await prisma.studentAssociation.findUnique({ 169 - where: { id: oldGroup.studentAssociationId ?? '' }, 170 - }); 171 - 172 - if ( 173 - !(user.canEditGroups || user.admin) && 174 - (oldGroup?.type != type || 175 - (oldStudentAssociation && oldStudentAssociation.uid != studentAssociationUid)) 176 - ) 177 - // Non admin users aren't allowed to change attached ae and group type 178 - throw new GraphQLError("Vous n'êtes pas autorisé à modifer ces paramètres."); 179 - } 180 - 181 - const data = { 182 - type, 183 - selfJoinable, 184 - name, 185 - color: color ?? undefined, 186 - familyRoot: familyId ? { connect: { id: familyId } } : undefined, 187 - address, 188 - description, 189 - website, 190 - email: email ?? undefined, 191 - mailingList: mailingList ?? undefined, 192 - longDescription, 193 - }; 194 - 195 - const group = await prisma.group.upsert({ 196 - ...query, 197 - where: { uid: oldUid ?? '' }, 198 - create: { 199 - ...data, 200 - color: color ?? '', 201 - uid: newUid ?? '', 202 - related: { connect: related.map((uid) => ({ uid })) }, 203 - parent: 204 - parentUid === null || parentUid === undefined ? {} : { connect: { uid: parentUid } }, 205 - studentAssociation: studentAssociationUid 206 - ? { connect: { uid: studentAssociationUid } } 207 - : {}, 208 - }, 209 - update: { 210 - ...data, 211 - related: { 212 - set: related.map((uid) => ({ uid })), 213 - }, 214 - parent: 215 - parentUid === null || parentUid === undefined 216 - ? { disconnect: true } 217 - : { connect: { uid: parentUid } }, 218 - studentAssociation: studentAssociationUid 219 - ? { connect: { uid: studentAssociationUid } } 220 - : {}, 221 - }, 222 - }); 223 - if ((await prisma.groupMember.count({ where: { groupId: group.id } })) === 0) { 224 - await prisma.group.update({ 225 - where: { id: group.id }, 226 - data: { 227 - members: { 228 - create: { 229 - president: true, 230 - title: 'Prez', 231 - member: { 232 - connect: { 233 - uid: user.uid, 234 - }, 235 - }, 236 - }, 237 - }, 238 - }, 239 - }); 240 - purgeSessionsUser(user.uid); 241 - } 242 - 243 - await log('groups', oldUid ? 'update' : 'create', group, group.uid, user); 244 - return group; 245 - }, 246 - }), 247 - );
+4 -4
packages/api/src/modules/groups/utils/permissions.ts
··· 23 23 export function canCreateGroup( 24 24 user: Context['user'], 25 25 { 26 - studentAssociationUid, 26 + studentAssociationId, 27 27 parentUid, 28 28 type, 29 29 }: { 30 - studentAssociationUid?: string | null | undefined; 30 + studentAssociationId?: string | null | undefined; 31 31 /** @deprecated setting parent group is done in another mutation now */ 32 32 parentUid?: string | null | undefined; 33 33 type: GroupType; 34 34 }, 35 35 ): boolean { 36 36 if (!user) return false; 37 - if (userIsAdminOf(user, studentAssociationUid ?? null)) return true; 38 - if (userIsGroupEditorOf(user, studentAssociationUid ?? null)) return true; 37 + if (userIsAdminOf(user, studentAssociationId ?? null)) return true; 38 + if (userIsGroupEditorOf(user, studentAssociationId ?? null)) return true; 39 39 40 40 if ( 41 41 parentUid &&
+5 -5
packages/api/src/modules/student-associations/types/student-association.ts
··· 6 6 canEditDetails, 7 7 userContributesTo, 8 8 } from '#modules/student-associations/utils'; 9 - import type { Prisma } from '@churros/db/prisma'; 9 + import { type Prisma, GroupType as GroupTypeEnum } from '@churros/db/prisma'; 10 10 11 11 export const StudentAssociationPrismaIncludes = { 12 12 contributionOptions: true, ··· 183 183 "Quel type de groupe l'on souhaiterait créer. Si non spécifié, renvoie vrai si l'on peut créer au moins un type de groupe", 184 184 }), 185 185 }, 186 - resolve: async ({ uid }, { type }, { user }) => { 186 + resolve: async ({ id }, { type }, { user }) => { 187 187 if (type) { 188 188 return canCreateGroup(user, { 189 - studentAssociationUid: uid, 189 + studentAssociationId: id, 190 190 type, 191 191 }); 192 192 } 193 193 194 - return Object.values(GroupEnumType).some((type) => 194 + return Object.values(GroupTypeEnum).some((type) => 195 195 canCreateGroup(user, { 196 - studentAssociationUid: uid, 196 + studentAssociationId: id, 197 197 type, 198 198 }), 199 199 );
-34
packages/app/schema.graphql
··· 3242 3242 godparentUid: String! 3243 3243 id: ID 3244 3244 ): MutationUpsertGodparentRequestResult! 3245 - upsertGroup(input: UpsertGroupInput!, uid: UID): MutationUpsertGroupResult! 3246 - @deprecated( 3247 - reason: "Mutation séparée en plusieurs mutations plus spécifiques. Voir la documentation du module groups" 3248 - ) 3249 3245 upsertGroupMember( 3250 3246 canEditArticles: Boolean! 3251 3247 canEditMembers: Boolean! ··· 4039 4035 4040 4036 type MutationUpsertGodparentRequestSuccess { 4041 4037 data: GodparentRequest! 4042 - } 4043 - 4044 - union MutationUpsertGroupResult = Error | MutationUpsertGroupSuccess | ZodError 4045 - 4046 - type MutationUpsertGroupSuccess { 4047 - data: Group! 4048 4038 } 4049 4039 4050 4040 union MutationUpsertLydiaAccountResult = Error | MutationUpsertLydiaAccountSuccess | ZodError ··· 6579 6569 Une adresse internet (URL). Les protocoles autorisés sont: http:, https:, mailto:, tel: 6580 6570 """ 6581 6571 scalar URL 6582 - 6583 - input UpsertGroupInput @graphinx(module: "groups") { 6584 - address: String! 6585 - color: String 6586 - description: String! 6587 - email: String 6588 - longDescription: String! 6589 - mailingList: String 6590 - name: String! 6591 - parent: UID 6592 - related: [String!]! 6593 - school: UID 6594 - @deprecated( 6595 - reason: "N'a aucun effet, les groupes ne peuvent plus être reliés à des écoles directement" 6596 - ) 6597 - selfJoinable: Boolean! 6598 - studentAssociation: UID 6599 - type: GroupType! 6600 - """ 6601 - Ne sert qu'à la création du groupe. Il est impossible de modifier un uid existant 6602 - """ 6603 - uid: UID 6604 - website: String! 6605 - } 6606 6572 6607 6573 """ 6608 6574 Users are the people who use the app
-269
packages/app/src/lib/components/FormGroup.svelte
··· 1 - <script lang="ts"> 2 - import { goto } from '$app/navigation'; 3 - import { fragment, graphql, type FormGroup } from '$houdini'; 4 - import Alert from '$lib/components/Alert.svelte'; 5 - import { DISPLAY_GROUP_TYPES } from '$lib/display'; 6 - import { mutationErrorMessages, mutationSucceeded } from '$lib/errors'; 7 - import { toasts } from '$lib/toasts'; 8 - import ButtonPrimary from './ButtonPrimary.svelte'; 9 - import InputCheckbox from './InputCheckbox.svelte'; 10 - import InputGroups from './InputGroups.svelte'; 11 - import InputLongText from './InputLongText.svelte'; 12 - import InputSelectOne from './InputSelectOne.svelte'; 13 - import InputSocialLinks from './InputSocialLinks.svelte'; 14 - import InputStudentAssociations from './InputStudentAssociations.svelte'; 15 - import InputText from './InputText.svelte'; 16 - 17 - // export let data: PageData; 18 - export let creatingSubgroup = false; 19 - 20 - export let group: FormGroup; 21 - $: data = fragment( 22 - group, 23 - graphql(` 24 - fragment FormGroup on Group { 25 - uid 26 - address 27 - color 28 - description 29 - email 30 - mailingList 31 - longDescription 32 - website 33 - canEditDetails 34 - name 35 - selfJoinable 36 - type 37 - parent { 38 - uid 39 - name 40 - id 41 - pictureFile 42 - pictureFileDark 43 - } 44 - related { 45 - uid 46 - name 47 - id 48 - pictureFile 49 - pictureFileDark 50 - } 51 - studentAssociation { 52 - uid 53 - id 54 - name 55 - } 56 - links { 57 - name 58 - value 59 - } 60 - } 61 - `), 62 - ); 63 - 64 - let serverError = ''; 65 - 66 - $: ({ 67 - address, 68 - description, 69 - color, 70 - email, 71 - mailingList, 72 - longDescription, 73 - website, 74 - name, 75 - selfJoinable, 76 - type, 77 - parent, 78 - related, 79 - studentAssociation, 80 - } = $data); 81 - 82 - const socialMediaNames = [ 83 - 'facebook', 84 - 'instagram', 85 - 'discord', 86 - 'twitter', 87 - 'linkedin', 88 - 'github', 89 - 'hackernews', 90 - 'anilist', 91 - ] as const; 92 - 93 - let links = socialMediaNames.map((name) => ({ 94 - name, 95 - value: $data?.links.find((link) => link.name === name)?.value ?? '', 96 - })); 97 - 98 - let loading = false; 99 - const submit = async () => { 100 - if (loading) return; 101 - try { 102 - loading = true; 103 - // const { upsertGroup } = await $zeus.mutate({ 104 - // upsertGroup: [ 105 - // { 106 - // uid: data.group.uid, 107 - // input: { 108 - // address, 109 - // color, 110 - // description, 111 - // email: email || undefined, 112 - // mailingList: mailingList || undefined, 113 - // longDescription, 114 - // website, 115 - // name, 116 - // selfJoinable, 117 - // parent: parent?.uid, 118 - // type, 119 - // related: related.map(({ uid }) => uid), 120 - // studentAssociation: studentAssociation?.uid, 121 - // }, 122 - // }, 123 - // { 124 - // '__typename': true, 125 - // '...on Error': { message: true }, 126 - // '...on ZodError': { message: true }, 127 - // '...on MutationUpsertGroupSuccess': { data: clubQuery }, 128 - // }, 129 - // ], 130 - // }); 131 - 132 - const result = await graphql(` 133 - mutation UpsertGroup($uid: UID!, $input: UpsertGroupInput!) { 134 - upsertGroup(uid: $uid, input: $input) { 135 - __typename 136 - ... on MutationUpsertGroupSuccess { 137 - data { 138 - ...FormGroup 139 - } 140 - } 141 - ...MutationErrors 142 - } 143 - } 144 - `).mutate({ 145 - uid: $data.uid, 146 - input: { 147 - address, 148 - color, 149 - description, 150 - email: email || undefined, 151 - mailingList: mailingList || undefined, 152 - longDescription, 153 - website, 154 - name, 155 - selfJoinable, 156 - parent: parent?.uid, 157 - type, 158 - related: related.map(({ uid }) => uid), 159 - studentAssociation: studentAssociation?.uid, 160 - }, 161 - }); 162 - 163 - if (mutationSucceeded('upsertGroup', result)) { 164 - serverError = ''; 165 - toasts.success(`${$data.name} mis à jour`); 166 - if ($data.uid) await goto(`/groups/${$data.uid}/edit`); 167 - } else { 168 - serverError = mutationErrorMessages('upsertGroup', result).join(', '); 169 - return; 170 - } 171 - } finally { 172 - loading = false; 173 - } 174 - }; 175 - 176 - const AllGroups = graphql(` 177 - query AllGroups { 178 - groups { 179 - uid 180 - name 181 - id 182 - pictureFile 183 - pictureFileDark 184 - } 185 - } 186 - `); 187 - </script> 188 - 189 - {#await AllGroups.fetch().then((d) => d.data ?? { groups: [] })} 190 - <p class="loading muted">Chargement...</p> 191 - {:then { groups: allGroups }} 192 - <form on:submit|preventDefault={submit}> 193 - {#if $data.canEditDetails} 194 - <InputSelectOne 195 - label="Type de groupe" 196 - required 197 - options={DISPLAY_GROUP_TYPES} 198 - bind:value={type} 199 - /> 200 - 201 - <div class="side-by-side"> 202 - <InputStudentAssociations 203 - clearable 204 - label="AE de rattachement" 205 - bind:association={studentAssociation} 206 - required={['Club', 'List'].includes(type)} 207 - ></InputStudentAssociations> 208 - </div> 209 - {/if} 210 - 211 - <InputCheckbox 212 - label="Inscription libre" 213 - help="N'importe qui peut rejoindre le groupe" 214 - bind:value={selfJoinable} 215 - /> 216 - 217 - <InputText required label="Nom" maxlength={255} bind:value={name} /> 218 - <InputText label="Description courte" maxlength={255} bind:value={description} /> 219 - <InputLongText rich label="Description" bind:value={longDescription} /> 220 - <!-- TODO colors ? --> 221 - <InputText label="Salle" maxlength={255} bind:value={address} /> 222 - <InputText 223 - label="Email" 224 - type="email" 225 - maxlength={255} 226 - value={email ?? ''} 227 - on:input={({ detail }) => { 228 - email = detail.currentTarget.value || null; 229 - }} 230 - /> 231 - <InputText label="Mailing list" type="email" maxlength={255} bind:value={mailingList} /> 232 - <InputText label="Site web" type="url" maxlength={255} bind:value={website} /> 233 - <InputSocialLinks label="Réseaux sociaux" bind:value={links} /> 234 - {#if !creatingSubgroup} 235 - <InputGroups clearable label="Groupe parent" bind:group={parent} options={allGroups} 236 - ></InputGroups> 237 - {/if} 238 - <InputGroups multiple label="Groupes à voir" bind:groups={related} options={allGroups} /> 239 - 240 - {#if serverError} 241 - <Alert theme="danger" 242 - >Impossible de sauvegarder les modifications : <br /><strong>{serverError}</strong></Alert 243 - > 244 - {/if} 245 - <section class="submit"> 246 - <ButtonPrimary submits {loading}>Sauvegarder</ButtonPrimary> 247 - </section> 248 - </form> 249 - {/await} 250 - 251 - <style> 252 - form { 253 - display: flex; 254 - flex-flow: column wrap; 255 - gap: 2rem; 256 - } 257 - 258 - section.submit { 259 - display: flex; 260 - justify-content: center; 261 - } 262 - 263 - .side-by-side { 264 - display: flex; 265 - flex-wrap: wrap; 266 - column-gap: 1rem; 267 - align-items: center; 268 - } 269 - </style>