diff --git a/backend/src/jwt/encode.js b/backend/src/jwt/encode.js index 9126f2577..50e439474 100644 --- a/backend/src/jwt/encode.js +++ b/backend/src/jwt/encode.js @@ -5,7 +5,7 @@ import CONFIG from './../config' export default function encode(user) { const { id, name, slug } = user const token = jwt.sign({ id, name, slug }, CONFIG.JWT_SECRET, { - expiresIn: '1d', + expiresIn: '2y', issuer: CONFIG.GRAPHQL_URI, audience: CONFIG.CLIENT_URI, subject: user.id.toString(), diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 25598a30f..b10389f50 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -88,7 +88,7 @@ const noEmailFilter = rule({ return !('email' in args) }) -const publicRegistration = rule()(() => !!CONFIG.PUBLIC_REGISTRATION) +const publicRegistration = rule()(() => CONFIG.PUBLIC_REGISTRATION) const inviteRegistration = rule()(async (_parent, args, { user, driver }) => { if (!CONFIG.INVITE_REGISTRATION) return false @@ -132,6 +132,7 @@ export default shield( VerifyNonce: 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 6bb1401cd..442ff17b1 100644 --- a/backend/src/schema/resolvers/inviteCodes.js +++ b/backend/src/schema/resolvers/inviteCodes.js @@ -13,6 +13,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/resolvers/users/location.spec.js b/backend/src/schema/resolvers/users/location.spec.js index 41b784249..1ef427034 100644 --- a/backend/src/schema/resolvers/users/location.spec.js +++ b/backend/src/schema/resolvers/users/location.spec.js @@ -114,10 +114,22 @@ describe('Location Service', () => { const result = await query({ query: queryLocations, variables }) expect(result.data.queryLocations).toEqual([ { id: 'place.14094307404564380', place_name: 'Berlin, Germany' }, - { id: 'place.15095411613564380', place_name: 'Berlin, Maryland, United States' }, - { id: 'place.5225018734564380', place_name: 'Berlin, Connecticut, United States' }, - { id: 'place.16922023226564380', place_name: 'Berlin, New Jersey, United States' }, - { id: 'place.4035845612564380', place_name: 'Berlin Township, New Jersey, United States' }, + { + id: expect.stringMatching(/^place\.[0-9]+$/), + place_name: 'Berlin, Maryland, United States', + }, + { + id: expect.stringMatching(/^place\.[0-9]+$/), + place_name: 'Berlin, Connecticut, United States', + }, + { + id: expect.stringMatching(/^place\.[0-9]+$/), + place_name: 'Berlin, New Jersey, United States', + }, + { + id: expect.stringMatching(/^place\.[0-9]+$/), + place_name: 'Berlin Township, New Jersey, United States', + }, ]) }) @@ -128,11 +140,23 @@ describe('Location Service', () => { } const result = await query({ query: queryLocations, variables }) expect(result.data.queryLocations).toEqual([ - { id: 'place.14094307404564380', place_name: 'Berlin, Deutschland' }, - { id: 'place.15095411613564380', place_name: 'Berlin, Maryland, Vereinigte Staaten' }, - { id: 'place.16922023226564380', place_name: 'Berlin, New Jersey, Vereinigte Staaten' }, - { id: 'place.10735893248465990', place_name: 'Berlin Heights, Ohio, Vereinigte Staaten' }, - { id: 'place.1165756679564380', place_name: 'Berlin, Massachusetts, Vereinigte Staaten' }, + { id: expect.stringMatching(/^place\.[0-9]+$/), place_name: 'Berlin, Deutschland' }, + { + id: expect.stringMatching(/^place\.[0-9]+$/), + place_name: 'Berlin, Maryland, Vereinigte Staaten', + }, + { + id: expect.stringMatching(/^place\.[0-9]+$/), + place_name: 'Berlin, New Jersey, Vereinigte Staaten', + }, + { + id: expect.stringMatching(/^place\.[0-9]+$/), + place_name: 'Berlin Heights, Ohio, Vereinigte Staaten', + }, + { + id: expect.stringMatching(/^place\.[0-9]+$/), + place_name: 'Berlin, Massachusetts, Vereinigte Staaten', + }, ]) }) 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/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/Editor/Editor.spec.js b/webapp/components/Editor/Editor.spec.js index ee762b332..f51c5782f 100644 --- a/webapp/components/Editor/Editor.spec.js +++ b/webapp/components/Editor/Editor.spec.js @@ -99,6 +99,37 @@ describe('Editor.vue', () => { }) }) + it('suggestion list returns results prefixed by query', () => { + const manyUsersList = [] + for (let i = 0; i < 10; i++) { + manyUsersList.push({ id: `user${i}` }) + manyUsersList.push({ id: `admin${i}` }) + manyUsersList.push({ id: `moderator${i}` }) + } + propsData.users = manyUsersList + wrapper = Wrapper() + const suggestionList = wrapper.vm.editor.extensions.options.mention.onFilter( + propsData.users, + 'moderator', + ) + expect(suggestionList).toHaveLength(10) + for (var i = 0; i < suggestionList.length; i++) { + expect(suggestionList[i].id).toMatch(/^moderator.*/) + } + }) + + it('exact match appears at the top of suggestion list', () => { + const manyUsersList = [] + for (let i = 0; i < 25; i++) { + manyUsersList.push({ id: `user${i}` }) + } + propsData.users = manyUsersList + wrapper = Wrapper() + expect( + wrapper.vm.editor.extensions.options.mention.onFilter(propsData.users, 'user7')[0].id, + ).toMatch('user7') + }) + it('sets the Hashtag items to the hashtags', () => { propsData.hashtags = [ { diff --git a/webapp/components/Editor/Editor.vue b/webapp/components/Editor/Editor.vue index 67329a60b..cf0fd710b 100644 --- a/webapp/components/Editor/Editor.vue +++ b/webapp/components/Editor/Editor.vue @@ -185,6 +185,9 @@ export default { if (this.suggestionType === HASHTAG && this.query !== '') { this.selectItem({ id: this.query }) } + if (this.suggestionType === MENTION && item) { + this.selectItem(item) + } return true default: @@ -199,9 +202,14 @@ export default { const filteredList = items.filter((item) => { const itemString = item.slug || item.id - return itemString.toLowerCase().includes(query.toLowerCase()) + return itemString.toLowerCase().startsWith(query.toLowerCase()) }) - return filteredList.slice(0, 15) + const sortedList = filteredList.sort((itemA, itemB) => { + const aString = itemA.slug || itemA.id + const bString = itemB.slug || itemB.id + return aString.length - bString.length + }) + return sortedList.slice(0, 15) }, sanitizeQuery(query) { if (this.suggestionType === HASHTAG) { 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 5fc9e093f..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 = { 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 1070c7748..cb060aa57 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -354,6 +354,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 bb620993a..4dc4e99ef 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -354,6 +354,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/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 ''