feat(webapp): location on registration (#8608)

* feat(webapp): location on registration

* add location name to signup verification, allow location query

* location name can be prompted in regeistration

* default value null for locationName

* Prevent ds-select overflow

* Remove location name from label

* Add margin-bottom to location-select

* group location is not affected by REQUIRE_LOCATION, previous location is shown

* Update webapp/components/Registration/RegistrationSlideCreate.vue

Co-authored-by: Max <maxharz@gmail.com>

* Replace more '16px' by '$space-small' and remove class 'password-strength'

* Add class 'password-strength' again

* property for previous location

---------

Co-authored-by: Maximilian Harz <maxharz@gmail.com>
Co-authored-by: Wolfgang Huß <wolle.huss@pjannto.com>
This commit is contained in:
Moriz Wahl 2025-06-04 15:16:24 +02:00 committed by GitHub
parent 8c30098ac3
commit df4275c5cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 147 additions and 31 deletions

View File

@ -13,6 +13,7 @@ import existingEmailAddress from './helpers/existingEmailAddress'
import generateNonce from './helpers/generateNonce' import generateNonce from './helpers/generateNonce'
import normalizeEmail from './helpers/normalizeEmail' import normalizeEmail from './helpers/normalizeEmail'
import { redeemInviteCode } from './inviteCodes' import { redeemInviteCode } from './inviteCodes'
import { createOrUpdateLocations } from './users/location'
const neode = getNeode() const neode = getNeode()
@ -43,13 +44,16 @@ export default {
} }
args.termsAndConditionsAgreedAt = new Date().toISOString() args.termsAndConditionsAgreedAt = new Date().toISOString()
let { nonce, email, inviteCode } = args let { nonce, email, inviteCode, locationName } = args
email = normalizeEmail(email) email = normalizeEmail(email)
delete args.nonce delete args.nonce
delete args.email delete args.email
delete args.inviteCode delete args.inviteCode
args.encryptedPassword = await hash(args.password, 10) args.encryptedPassword = await hash(args.password, 10)
delete args.password delete args.password
delete args.locationName
if (locationName === '') locationName = null
const { driver } = context const { driver } = context
const session = driver.session() const session = driver.session()
@ -68,6 +72,7 @@ export default {
SET user.updatedAt = toString(datetime()) SET user.updatedAt = toString(datetime())
SET user.allowEmbedIframes = false SET user.allowEmbedIframes = false
SET user.showShoutsPublicly = false SET user.showShoutsPublicly = false
SET user.locationName = $locationName
SET email.verifiedAt = toString(datetime()) SET email.verifiedAt = toString(datetime())
WITH user WITH user
OPTIONAL MATCH (post:Post)-[:IN]->(group:Group) OPTIONAL MATCH (post:Post)-[:IN]->(group:Group)
@ -83,6 +88,7 @@ export default {
nonce, nonce,
email, email,
inviteCode, inviteCode,
locationName,
}, },
) )
const [user] = createUserTransactionResponse.records.map((record) => record.get('user')) const [user] = createUserTransactionResponse.records.map((record) => record.get('user'))
@ -100,6 +106,7 @@ export default {
await redeemInviteCode(context, inviteCode, true) await redeemInviteCode(context, inviteCode, true)
} }
await createOrUpdateLocations('User', user.id, locationName, session)
return user return user
} catch (e) { } catch (e) {
if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')

View File

@ -24,6 +24,7 @@ type Mutation {
about: String about: String
termsAndConditionsAgreedVersion: String! termsAndConditionsAgreedVersion: String!
locale: String locale: String
locationName: String = null
): User ): User
AddEmailAddress(email: String!): EmailAddress AddEmailAddress(email: String!): EmailAddress
VerifyEmailAddress( VerifyEmailAddress(

View File

@ -428,7 +428,7 @@ export default shield(
Donations: isAuthenticated, Donations: isAuthenticated,
userData: isAuthenticated, userData: isAuthenticated,
VerifyNonce: allow, VerifyNonce: allow,
queryLocations: isAuthenticated, queryLocations: allow,
availableRoles: isAdmin, availableRoles: isAdmin,
Room: isAuthenticated, Room: isAuthenticated,
Message: isAuthenticated, Message: isAuthenticated,

View File

@ -10,3 +10,5 @@ INVITE_LINK_LIMIT=7
NETWORK_NAME="Ocelot.social" NETWORK_NAME="Ocelot.social"
ASK_FOR_REAL_NAME=false ASK_FOR_REAL_NAME=false
REQUIRE_LOCATION=false

View File

@ -187,3 +187,11 @@ body.dropdown-open {
.dropdown-arrow { .dropdown-arrow {
font-size: $font-size-xx-small; font-size: $font-size-xx-small;
} }
/* Prevent ds-select overflow */
.ds-select-value {
max-height: 38px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -96,6 +96,15 @@
</div> </div>
<password-strength class="password-strength" :password="formData.password" /> <password-strength class="password-strength" :password="formData.password" />
<!-- location -->
<location-select
v-if="locationRequired"
class="location-select"
v-model="locationName"
:canBeCleared="false"
:showPreviousLocation="false"
/>
<email-display-and-verify :email="sliderData.collectedInputData.email" /> <email-display-and-verify :email="sliderData.collectedInputData.email" />
<ds-text> <ds-text>
<input <input
@ -148,6 +157,7 @@ import EmailDisplayAndVerify from './EmailDisplayAndVerify'
import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink' import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink'
import PasswordForm from '~/components/utils/PasswordFormHelper' import PasswordForm from '~/components/utils/PasswordFormHelper'
import ShowPassword from '../ShowPassword/ShowPassword.vue' import ShowPassword from '../ShowPassword/ShowPassword.vue'
import LocationSelect from '~/components/Select/LocationSelect'
const threePerEmSpace = '' // unicode u+2004; const threePerEmSpace = '' // unicode u+2004;
@ -159,6 +169,7 @@ export default {
PasswordStrength, PasswordStrength,
ShowPassword, ShowPassword,
SweetalertIcon, SweetalertIcon,
LocationSelect,
}, },
props: { props: {
sliderData: { type: Object, required: true }, sliderData: { type: Object, required: true },
@ -196,6 +207,7 @@ export default {
// TODO: Our styleguide does not support checkmarks. // TODO: Our styleguide does not support checkmarks.
// Integrate termsAndConditionsConfirmed into `this.formData` once we // Integrate termsAndConditionsConfirmed into `this.formData` once we
// have checkmarks available. // have checkmarks available.
locationName: '',
termsAndConditionsConfirmed: false, termsAndConditionsConfirmed: false,
receiveCommunicationAsEmailsEtcConfirmed: false, receiveCommunicationAsEmailsEtcConfirmed: false,
showPassword: false, showPassword: false,
@ -213,24 +225,16 @@ export default {
this.formData.givenName = '' this.formData.givenName = ''
} }
} else { } else {
this.formData.name = this.sliderData.collectedInputData.name this.formData.name = this.sliderData.collectedInputData.name || ''
? this.sliderData.collectedInputData.name
: ''
} }
this.formData.password = this.sliderData.collectedInputData.password this.formData.password = this.sliderData.collectedInputData.password || ''
? this.sliderData.collectedInputData.password this.formData.passwordConfirmation =
: '' this.sliderData.collectedInputData.passwordConfirmation || ''
this.formData.passwordConfirmation = this.sliderData.collectedInputData.passwordConfirmation this.termsAndConditionsConfirmed =
? this.sliderData.collectedInputData.passwordConfirmation this.sliderData.collectedInputData.termsAndConditionsConfirmed || false
: '' this.receiveCommunicationAsEmailsEtcConfirmed =
this.termsAndConditionsConfirmed = this.sliderData.collectedInputData this.sliderData.collectedInputData.receiveCommunicationAsEmailsEtcConfirmed || false
.termsAndConditionsConfirmed this.locationName = this.sliderData.collectedInputData.locationName || ''
? this.sliderData.collectedInputData.termsAndConditionsConfirmed
: false
this.receiveCommunicationAsEmailsEtcConfirmed = this.sliderData.collectedInputData
.receiveCommunicationAsEmailsEtcConfirmed
? this.sliderData.collectedInputData.receiveCommunicationAsEmailsEtcConfirmed
: false
this.sendValidation() this.sendValidation()
this.sliderData.setSliderValuesCallback(this.validInput, { this.sliderData.setSliderValuesCallback(this.validInput, {
@ -238,6 +242,16 @@ export default {
}) })
}, },
computed: { computed: {
formLocationName() {
// toDo: Mixin or move it to location select component
const isNestedValue =
typeof this.locationName === 'object' && typeof this.locationName.value === 'string'
const isDirectString = typeof this.locationName === 'string'
return isNestedValue ? this.locationName.value : isDirectString ? this.locationName : ''
},
locationRequired() {
return this.$env.REQUIRE_LOCATION
},
askForRealName() { askForRealName() {
return this.$env.ASK_FOR_REAL_NAME return this.$env.ASK_FOR_REAL_NAME
}, },
@ -249,7 +263,8 @@ export default {
this.formData.password.length >= 1 && this.formData.password.length >= 1 &&
this.formData.password === this.formData.passwordConfirmation && this.formData.password === this.formData.passwordConfirmation &&
this.termsAndConditionsConfirmed && this.termsAndConditionsConfirmed &&
this.receiveCommunicationAsEmailsEtcConfirmed this.receiveCommunicationAsEmailsEtcConfirmed &&
(this.locationRequired ? this.formLocationName : true)
) )
}, },
iconNamePassword() { iconNamePassword() {
@ -266,6 +281,9 @@ export default {
receiveCommunicationAsEmailsEtcConfirmed() { receiveCommunicationAsEmailsEtcConfirmed() {
this.sendValidation() this.sendValidation()
}, },
locationName() {
this.sendValidation()
},
}, },
methods: { methods: {
buildName(data) { buildName(data) {
@ -276,6 +294,7 @@ export default {
const { password, passwordConfirmation } = this.formData const { password, passwordConfirmation } = this.formData
const name = this.buildName(this.formData) const name = this.buildName(this.formData)
const { termsAndConditionsConfirmed, receiveCommunicationAsEmailsEtcConfirmed } = this const { termsAndConditionsConfirmed, receiveCommunicationAsEmailsEtcConfirmed } = this
const locationName = this.formLocationName
this.sliderData.setSliderValuesCallback(this.validInput, { this.sliderData.setSliderValuesCallback(this.validInput, {
collectedInputData: { collectedInputData: {
@ -284,6 +303,7 @@ export default {
passwordConfirmation, passwordConfirmation,
termsAndConditionsConfirmed, termsAndConditionsConfirmed,
receiveCommunicationAsEmailsEtcConfirmed, receiveCommunicationAsEmailsEtcConfirmed,
locationName,
}, },
}) })
}, },
@ -299,6 +319,7 @@ export default {
const { email, inviteCode = null, nonce } = this.sliderData.collectedInputData const { email, inviteCode = null, nonce } = this.sliderData.collectedInputData
const termsAndConditionsAgreedVersion = VERSION const termsAndConditionsAgreedVersion = VERSION
const locale = this.$i18n.locale() const locale = this.$i18n.locale()
try { try {
this.sliderData.setSliderValuesCallback(null, { this.sliderData.setSliderValuesCallback(null, {
sliderSettings: { buttonLoading: true }, sliderSettings: { buttonLoading: true },
@ -313,6 +334,7 @@ export default {
nonce, nonce,
termsAndConditionsAgreedVersion, termsAndConditionsAgreedVersion,
locale, locale,
locationName: this.formLocationName,
}, },
}) })
this.response = 'success' this.response = 'success'
@ -378,7 +400,7 @@ export default {
padding-right: 0; padding-right: 0;
height: $input-height; height: $input-height;
margin-bottom: 10px; margin-bottom: 10px;
margin-bottom: 16px; margin-bottom: $space-small;
color: $text-color-base; color: $text-color-base;
background: $background-color-disabled; background: $background-color-disabled;
@ -400,7 +422,7 @@ export default {
.password-field { .password-field {
position: relative; position: relative;
padding-top: 16px; padding-top: $space-small;
border: none; border: none;
border-style: none; border-style: none;
appearance: none; appearance: none;
@ -410,6 +432,10 @@ export default {
} }
.full-name { .full-name {
padding-bottom: 16px; padding-bottom: $space-small;
}
.location-select {
padding-bottom: $space-base;
} }
</style> </style>

View File

@ -180,6 +180,7 @@ export default {
passwordConfirmation: null, passwordConfirmation: null,
termsAndConditionsConfirmed: null, termsAndConditionsConfirmed: null,
receiveCommunicationAsEmailsEtcConfirmed: null, receiveCommunicationAsEmailsEtcConfirmed: null,
locationName: null,
}, },
sliderIndex: sliderIndex:
this.activePage === null ? 0 : sliders.findIndex((el) => el.name === this.activePage), this.activePage === null ? 0 : sliders.findIndex((el) => el.name === this.activePage),

View File

@ -1,16 +1,31 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import LocationSelect from './LocationSelect' import LocationSelect from './LocationSelect'
import { queryLocations } from '~/graphql/location'
const localVue = global.localVue const localVue = global.localVue
const propsData = { value: 'nowhere' } const propsData = { value: 'nowhere' }
let wrapper let wrapper
const queryMock = jest.fn().mockResolvedValue({
data: {
queryLocations: [
{
place_name: 'Hamburg, Germany',
place_id: 'xxx',
},
],
},
})
const mocks = { const mocks = {
$t: jest.fn((string) => string), $t: jest.fn((string) => string),
$i18n: { $i18n: {
locale: () => 'en', locale: () => 'en',
}, },
$apollo: {
query: queryMock,
},
} }
describe('LocationSelect', () => { describe('LocationSelect', () => {
@ -25,18 +40,28 @@ describe('LocationSelect', () => {
wrapper = Wrapper() wrapper = Wrapper()
}) })
it('renders the label', () => { it('renders the label with previous location by default', () => {
expect(wrapper.find('label.ds-input-label').exists()).toBe(true) expect(wrapper.find('label.ds-input-label').text()).toBe('settings.data.labelCity — nowhere')
}) })
it('renders the select', () => { it('renders the select', () => {
expect(wrapper.find('.ds-select').exists()).toBe(true) expect(wrapper.find('.ds-select').exists()).toBe(true)
}) })
it('renders the clearLocationName button', () => { it('renders the clearLocationName button by default', () => {
expect(wrapper.find('.base-button').exists()).toBe(true) expect(wrapper.find('.base-button').exists()).toBe(true)
}) })
it('calls apollo with given value', () => {
expect(queryMock).toBeCalledWith({
query: queryLocations(),
variables: {
place: 'nowhere',
lang: 'en',
},
})
})
describe('clearLocationName button click', () => { describe('clearLocationName button click', () => {
beforeEach(() => { beforeEach(() => {
wrapper.find('.base-button').trigger('click') wrapper.find('.base-button').trigger('click')
@ -48,5 +73,27 @@ describe('LocationSelect', () => {
expect(wrapper.emitted().input[0]).toEqual(['']) expect(wrapper.emitted().input[0]).toEqual([''])
}) })
}) })
describe('canBeCleared is false', () => {
beforeEach(() => {
propsData.canBeCleared = false
wrapper = Wrapper()
})
it('does not show clear location name button', () => {
expect(wrapper.find('.base-button').exists()).toBe(false)
})
})
describe('showPreviousLocation is false', () => {
beforeEach(() => {
propsData.showPreviousLocation = false
wrapper = Wrapper()
})
it('does not show the previous location', () => {
expect(wrapper.find('.ds-input-label').text()).toBe('settings.data.labelCity')
})
})
}) })
}) })

View File

@ -1,8 +1,7 @@
<template> <template>
<div> <div>
<label class="ds-input-label"> <label class="ds-input-label">
{{ `${$t('settings.data.labelCity')}` }} {{ `${$t('settings.data.labelCity')}` + locationNameLabelAddOnOldName }}
<span v-if="locationName">{{ ` ${locationName}` }}</span>
</label> </label>
<ds-select <ds-select
id="city" id="city"
@ -15,7 +14,7 @@
@input.native="handleCityInput" @input.native="handleCityInput"
/> />
<base-button <base-button
v-if="locationName !== ''" v-if="locationName !== '' && canBeCleared"
icon="close" icon="close"
ghost ghost
size="small" size="small"
@ -36,6 +35,16 @@ export default {
value: { value: {
required: true, required: true,
}, },
canBeCleared: {
type: Boolean,
required: false,
default: true,
},
showPreviousLocation: {
type: Boolean,
required: false,
default: true,
},
}, },
async created() { async created() {
const result = await this.requestGeoData(this.locationName) const result = await this.requestGeoData(this.locationName)
@ -54,6 +63,9 @@ export default {
locationName() { locationName() {
return typeof this.value === 'object' ? this.value.value : this.value return typeof this.value === 'object' ? this.value.value : this.value
}, },
locationNameLabelAddOnOldName() {
return this.locationName !== '' && this.showPreviousLocation ? ' — ' + this.locationName : ''
},
}, },
watch: { watch: {
currentValue() { currentValue() {

View File

@ -38,6 +38,7 @@ const options = {
INVITE_LINK_LIMIT: process.env.INVITE_LINK_LIMIT || 7, INVITE_LINK_LIMIT: process.env.INVITE_LINK_LIMIT || 7,
NETWORK_NAME: process.env.NETWORK_NAME || 'Ocelot.social', NETWORK_NAME: process.env.NETWORK_NAME || 'Ocelot.social',
ASK_FOR_REAL_NAME: process.env.ASK_FOR_REAL_NAME === 'true' || false, ASK_FOR_REAL_NAME: process.env.ASK_FOR_REAL_NAME === 'true' || false,
REQUIRE_LOCATION: process.env.REQUIRE_LOCATION === 'true' || false,
} }
const language = { const language = {

View File

@ -9,6 +9,7 @@ export const SignupVerificationMutation = gql`
$about: String $about: String
$termsAndConditionsAgreedVersion: String! $termsAndConditionsAgreedVersion: String!
$locale: String $locale: String
$locationName: String
) { ) {
SignupVerification( SignupVerification(
nonce: $nonce nonce: $nonce
@ -19,6 +20,7 @@ export const SignupVerificationMutation = gql`
about: $about about: $about
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
locale: $locale locale: $locale
locationName: $locationName
) { ) {
id id
name name

View File

@ -15,7 +15,11 @@
:placeholder="$t('settings.data.namePlaceholder')" :placeholder="$t('settings.data.namePlaceholder')"
/> />
<ds-input id="slug" model="slug" icon="at" :label="$t('settings.data.labelSlug')" /> <ds-input id="slug" model="slug" icon="at" :label="$t('settings.data.labelSlug')" />
<location-select v-model="formData.locationName" /> <location-select
class="location-selet"
v-model="formData.locationName"
:canBeCleared="!$env.REQUIRE_LOCATION"
/>
<!-- eslint-enable vue/use-v-on-exact --> <!-- eslint-enable vue/use-v-on-exact -->
<ds-input <ds-input
id="about" id="about"
@ -76,6 +80,7 @@ export default {
translate: this.$t, translate: this.$t,
}) })
return { return {
locationName: { required: this.$env.REQUIRE_LOCATION },
name: { required: true, min: 3 }, name: { required: true, min: 3 },
...uniqueSlugForm.formSchema, ...uniqueSlugForm.formSchema,
} }
@ -122,4 +127,8 @@ export default {
.location-hint { .location-hint {
margin-top: -$space-x-small - $space-xxx-small - $space-xxx-small; margin-top: -$space-x-small - $space-xxx-small - $space-xxx-small;
} }
.location-selet {
margin-bottom: $space-small;
}
</style> </style>