diff --git a/backend/.env.template b/backend/.env.template index fc9766478..2d2374698 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -17,6 +17,7 @@ PRIVATE_KEY_PASSPHRASE="a7dsf78sadg87ad87sfagsadg78" SENTRY_DSN_BACKEND= COMMIT= PUBLIC_REGISTRATION=false +INVITE_REGISTRATION=true AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 47771029b..b7d500b8b 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -82,7 +82,8 @@ const options = { SUPPORT_URL: links.SUPPORT, APPLICATION_NAME: metadata.APPLICATION_NAME, ORGANIZATION_URL: links.ORGANIZATION, - PUBLIC_REGISTRATION: env.PUBLIC_REGISTRATION === 'true', + PUBLIC_REGISTRATION: env.PUBLIC_REGISTRATION === 'true' || false, + INVITE_REGISTRATION: env.INVITE_REGISTRATION !== 'false', // default = true } // Check if all required configs are present diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 7aeb7252a..e1b2feebe 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -87,7 +87,7 @@ const noEmailFilter = rule({ return !('email' in args) }) -const publicRegistration = rule()(() => !!CONFIG.PUBLIC_REGISTRATION) +const publicRegistration = rule()(() => CONFIG.PUBLIC_REGISTRATION) // Permissions export default shield( @@ -123,6 +123,7 @@ export default shield( isValidInviteCode: allow, queryLocations: isAuthenticated, availableRoles: isAdmin, + getInviteCode: isAuthenticated, // and inviteRegistration }, Mutation: { '*': deny, diff --git a/backend/src/schema/resolvers/inviteCodes.js b/backend/src/schema/resolvers/inviteCodes.js index 91148a08d..2a0269b54 100644 --- a/backend/src/schema/resolvers/inviteCodes.js +++ b/backend/src/schema/resolvers/inviteCodes.js @@ -12,6 +12,52 @@ const uniqueInviteCode = async (session, code) => { export default { Query: { + getInviteCode: async (_parent, args, context, _resolveInfo) => { + const { + user: { id: userId }, + } = context + const session = context.driver.session() + const readTxResultPromise = session.readTransaction(async (txc) => { + const result = await txc.run( + `MATCH (user:User {id: $userId})-[:GENERATED]->(ic:InviteCode) + WHERE ic.expiresAt IS NULL + OR datetime(ic.expiresAt) >= datetime() + RETURN properties(ic) AS inviteCodes`, + { + userId, + }, + ) + return result.records.map((record) => record.get('inviteCodes')) + }) + try { + const inviteCode = await readTxResultPromise + if (inviteCode && inviteCode.length > 0) return inviteCode[0] + let code = generateInviteCode() + while (!(await uniqueInviteCode(session, code))) { + code = generateInviteCode() + } + const writeTxResultPromise = session.writeTransaction(async (txc) => { + const result = await txc.run( + `MATCH (user:User {id: $userId}) + MERGE (user)-[:GENERATED]->(ic:InviteCode { code: $code }) + ON CREATE SET + ic.createdAt = toString(datetime()), + ic.expiresAt = $expiresAt + RETURN ic AS inviteCode`, + { + userId, + code, + expiresAt: null, + }, + ) + return result.records.map((record) => record.get('inviteCode').properties) + }) + const txResult = await writeTxResultPromise + return txResult[0] + } finally { + session.close() + } + }, MyInviteCodes: async (_parent, args, context, _resolveInfo) => { const { user: { id: userId }, diff --git a/backend/src/schema/types/type/InviteCode.gql b/backend/src/schema/types/type/InviteCode.gql index 8ad7851a2..3293c735b 100644 --- a/backend/src/schema/types/type/InviteCode.gql +++ b/backend/src/schema/types/type/InviteCode.gql @@ -14,4 +14,5 @@ type Mutation { type Query { MyInviteCodes: [InviteCode] isValidInviteCode(code: ID!): Boolean + getInviteCode: InviteCode } diff --git a/webapp/.env.template b/webapp/.env.template index 1acad49b4..7373255a9 100644 --- a/webapp/.env.template +++ b/webapp/.env.template @@ -1,5 +1,6 @@ SENTRY_DSN_WEBAPP= COMMIT= PUBLIC_REGISTRATION=false +INVITE_REGISTRATION=true WEBSOCKETS_URI=ws://localhost:3000/api/graphql GRAPHQL_URI=http://localhost:4000/ diff --git a/webapp/assets/_new/icons/svgs/copy.svg b/webapp/assets/_new/icons/svgs/copy.svg new file mode 100644 index 000000000..1792f2002 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/copy.svg @@ -0,0 +1,5 @@ + + +copy + + diff --git a/webapp/components/InviteButton/InviteButton.vue b/webapp/components/InviteButton/InviteButton.vue new file mode 100644 index 000000000..9eec37bc2 --- /dev/null +++ b/webapp/components/InviteButton/InviteButton.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/webapp/components/PageFooter/PageFooter.vue b/webapp/components/PageFooter/PageFooter.vue index 9b56e29ee..ace0514e1 100644 --- a/webapp/components/PageFooter/PageFooter.vue +++ b/webapp/components/PageFooter/PageFooter.vue @@ -31,7 +31,7 @@ import links from '~/constants/links.js' export default { data() { - return { links, version: `v${process.env.release}` } + return { links, version: `v${this.$env.VERSION}` } }, } diff --git a/webapp/config/index.js b/webapp/config/index.js index fd564f350..dd5a5e04d 100644 --- a/webapp/config/index.js +++ b/webapp/config/index.js @@ -12,7 +12,6 @@ const environment = { PRODUCTION: process.env.NODE_ENV === 'production' || false, NUXT_BUILD: process.env.NUXT_BUILD || '.nuxt', STYLEGUIDE_DEV: process.env.STYLEGUIDE_DEV || false, - RELEASE: process.env.release, } const server = { @@ -29,6 +28,8 @@ const sentry = { const options = { VERSION: process.env.VERSION || pkg.version, DESCRIPTION: process.env.DESCRIPTION || pkg.description, + PUBLIC_REGISTRATION: process.env.PUBLIC_REGISTRATION === 'true' || false, + INVITE_REGISTRATION: process.env.INVITE_REGISTRATION !== 'false', // default = true // Cookies COOKIE_EXPIRE_TIME: process.env.COOKIE_EXPIRE_TIME || 730, // Two years by default COOKIE_HTTPS_ONLY: process.env.COOKIE_HTTPS_ONLY || process.env.NODE_ENV === 'production', // ensure true in production if not set explicitly diff --git a/webapp/layouts/default.vue b/webapp/layouts/default.vue index 0bf98967e..0b682f3d5 100644 --- a/webapp/layouts/default.vue +++ b/webapp/layouts/default.vue @@ -50,6 +50,11 @@ +
+ + + +
@@ -84,6 +89,7 @@ import seo from '~/mixins/seo' import FilterMenu from '~/components/FilterMenu/FilterMenu.vue' import PageFooter from '~/components/PageFooter/PageFooter' import AvatarMenu from '~/components/AvatarMenu/AvatarMenu' +import InviteButton from '~/components/InviteButton/InviteButton' export default { components: { @@ -95,12 +101,14 @@ export default { AvatarMenu, FilterMenu, PageFooter, + InviteButton, }, mixins: [seo], data() { return { mobileSearchVisible: false, toggleMobileMenu: false, + inviteRegistration: this.$env.INVITE_REGISTRATION === true, // for 'false' in .env INVITE_REGISTRATION is of type undefined and not(!) boolean false, because of internal handling, } }, computed: { diff --git a/webapp/locales/de.json b/webapp/locales/de.json index e02e4f6b0..429764741 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -336,6 +336,12 @@ "change-filter-settings": "Verändere die Filter-Einstellungen, um mehr Ergebnisse zu erhalten.", "no-results": "Keine Beiträge gefunden." }, + "invite-codes": { + "copy-code": "Code:", + "copy-success": "Einladungscode erfolgreich in die Zwischenablage kopiert", + "not-available": "Du hast keinen Einladungscode zur Verfügung!", + "your-code": "Kopiere deinen Einladungscode in die Ablage:" + }, "login": { "email": "Deine E-Mail", "failure": "Fehlerhafte E-Mail-Adresse oder Passwort.", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index e195f542e..a9454b5c2 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -336,6 +336,12 @@ "change-filter-settings": "Change your filter settings to get more results.", "no-results": "No contributions found." }, + "invite-codes": { + "copy-code": "Code:", + "copy-success": "Invite code copied to clipboard", + "not-available": "You have no valid invite code available!", + "your-code": "Copy your invite code to the clipboard:" + }, "login": { "email": "Your E-mail", "failure": "Incorrect email address or password.", diff --git a/webapp/nuxt.config.js b/webapp/nuxt.config.js index 9f992ee5e..625710f97 100644 --- a/webapp/nuxt.config.js +++ b/webapp/nuxt.config.js @@ -27,7 +27,7 @@ export default { }, env: { - release: CONFIG.VERSION, + ...CONFIG, // pages which do NOT require a login publicPages: [ 'login', diff --git a/webapp/pages/registration/signup.vue b/webapp/pages/registration/signup.vue index 1265cc013..18f9c9a70 100644 --- a/webapp/pages/registration/signup.vue +++ b/webapp/pages/registration/signup.vue @@ -22,7 +22,7 @@ export default { }, asyncData({ app }) { return { - publicRegistration: app.$env.PUBLIC_REGISTRATION === 'true', + publicRegistration: app.$env.PUBLIC_REGISTRATION, } }, methods: { diff --git a/webapp/plugins/vue-filters.js b/webapp/plugins/vue-filters.js index e6fcaf1dc..94246bfc6 100644 --- a/webapp/plugins/vue-filters.js +++ b/webapp/plugins/vue-filters.js @@ -33,6 +33,18 @@ export default ({ app = {} }) => { } return trunc(value, length).html }, + truncateStr: (value = '', length = -1) => { + if (!value || typeof value !== 'string' || value.length <= 0) { + return '' + } + if (length <= 0) { + return value + } + if (length < value.length) { + return value.substring(0, length) + '…' + } + return value + }, list: (value, glue = ', ', truncate = 0) => { if (!Array.isArray(value) || !value.length) { return ''