From 77c1790f8bddbbbb374729a7384bdeed8e3649bb Mon Sep 17 00:00:00 2001
From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com>
Date: Tue, 18 Aug 2026 07:31:55 +0200
Subject: [PATCH] ENH - add CityMarkt password authentication
Authenticate CityMarkt profiles with the linked iFruit account password, including server-side validation and rate limiting. Update the shared auth UI, localized copy, frontend state, browser mocks, and regression coverage for login and registration flows.
---
.../src/components/account/AppProfileAuth.vue | 257 +++++------
.../components/citymarkt/CityMarktAuth.vue | 177 +++++++-
frontend/src/stores/marketplace.test.ts | 24 +
frontend/src/stores/marketplace.ts | 13 +
frontend/src/stores/phone.ts | 27 +-
frontend/src/views/apps/CityMarktApp.vue | 419 ++++++++++++------
frontend/testserver/index.cjs | 67 ++-
sky_phone/config/locales/de.lua | 22 +-
sky_phone/config/locales/en.lua | 18 +-
sky_phone/source/client/main.lua | 1 +
sky_phone/source/server/marketplace.lua | 65 ++-
11 files changed, 811 insertions(+), 279 deletions(-)
diff --git a/frontend/src/components/account/AppProfileAuth.vue b/frontend/src/components/account/AppProfileAuth.vue
index fe7e387..aff1a0c 100644
--- a/frontend/src/components/account/AppProfileAuth.vue
+++ b/frontend/src/components/account/AppProfileAuth.vue
@@ -8,13 +8,13 @@ import {
UserRound,
} from 'lucide-vue-next'
import {
- SkyButton as kButton,
- SkyField as kListInput,
- SkyGlass as kGlass,
- SkyList as kList,
- SkySegmented as kSegmented,
- SkySegmentedButton as kSegmentedButton,
- SkySpinner as kPreloader,
+ SkyButton,
+ SkyField,
+ SkyGlass,
+ SkyList,
+ SkySegmented,
+ SkySegmentedButton,
+ SkySpinner,
} from '@/ui'
import { computed } from 'vue'
@@ -24,6 +24,7 @@ const props = withDefaults(
body: string
cameraLabel: string
email: string
+ emailAsField?: boolean
emailLabel: string
error: string
eyebrow: string
@@ -38,19 +39,38 @@ const props = withDefaults(
pending: boolean
registerLabel: string
requirePassword?: boolean
+ submitEnabled?: boolean
title: string
username: string
+ usernameAutocomplete?: string
+ usernameHelp?: string
+ usernameInputType?: 'password' | 'text'
usernameLabel: string
usernamePlaceholder?: string
variant?: 'default' | 'centered'
+ confirmPassword?: string
+ confirmPasswordError?: boolean | string
+ confirmPasswordLabel?: string
+ confirmPasswordPlaceholder?: string
+ showConfirmPassword?: boolean
}>(),
{
+ confirmPassword: '',
+ confirmPasswordError: false,
+ confirmPasswordLabel: '',
+ confirmPasswordPlaceholder: '',
+ emailAsField: false,
maxUsernameLength: 40,
minUsernameLength: 2,
password: '',
passwordLabel: 'Password',
passwordPlaceholder: '',
requirePassword: false,
+ showConfirmPassword: false,
+ submitEnabled: undefined,
+ usernameAutocomplete: 'username',
+ usernameHelp: '',
+ usernameInputType: 'text',
usernamePlaceholder: '',
variant: 'default',
},
@@ -59,6 +79,7 @@ const emit = defineEmits<{
camera: []
gallery: []
submit: []
+ 'update:confirmPassword': [value: string]
'update:mode': [value: 'login' | 'register']
'update:password': [value: string]
'update:username': [value: string]
@@ -68,13 +89,16 @@ const canSubmit = computed(() => {
const length = props.username.trim().length
const validUsername =
length >= props.minUsernameLength && length <= props.maxUsernameLength
- return Boolean(
- props.email &&
- (props.mode === 'login' && props.requirePassword
- ? true
- : validUsername) &&
- (!props.requirePassword ||
- (props.password.length >= 8 && props.password.length <= 72)),
+ return (
+ props.submitEnabled ??
+ Boolean(
+ props.email &&
+ (props.mode === 'login' && props.requirePassword
+ ? true
+ : validUsername) &&
+ (!props.requirePassword ||
+ (props.password.length >= 8 && props.password.length <= 72)),
+ )
)
})
@@ -93,41 +117,9 @@ const canSubmit = computed(() => {
{{ body }}
-
-
-
-
-
-
-
-
+
+
{{ loginLabel }}
-
-
+
{{ registerLabel }}
-
-
+
+
@@ -156,16 +148,16 @@ const canSubmit = computed(() => {
-
+
{{ galleryLabel }}
-
-
+
+
{{ cameraLabel }}
-
+
-
+
{{ emailLabel }}
@@ -174,71 +166,34 @@ const canSubmit = computed(() => {
-
-
-
-
-
-
+
+
+
+
+ {
emit('update:username', ($event.target as HTMLInputElement).value)
"
@keydown.enter="emit('submit')"
- />
-
+ >
+
+
+
+
+
+
+
+
+
+
+
{{ error }}
-
-
+
{{ mode === 'login' ? loginLabel : registerLabel }}
-
-
+
+
diff --git a/frontend/src/components/citymarkt/CityMarktAuth.vue b/frontend/src/components/citymarkt/CityMarktAuth.vue
index abd7caa..7dd0412 100644
--- a/frontend/src/components/citymarkt/CityMarktAuth.vue
+++ b/frontend/src/components/citymarkt/CityMarktAuth.vue
@@ -4,18 +4,20 @@ import { usePhoneStore } from '@/stores/phone'
defineProps<{
avatarUrl: string | null
+ confirmPassword: string
email: string
error: string
mode: 'login' | 'register'
+ password: string
pending: boolean
- username: string
}>()
const emit = defineEmits<{
camera: []
gallery: []
submit: []
+ 'update:confirmPassword': [value: string]
'update:mode': [value: 'login' | 'register']
- 'update:username': [value: string]
+ 'update:password': [value: string]
}>()
const phone = usePhoneStore()
@@ -23,25 +25,186 @@ const phone = usePhoneStore()
+
+
diff --git a/frontend/src/stores/marketplace.test.ts b/frontend/src/stores/marketplace.test.ts
index 431096b..a418ac7 100644
--- a/frontend/src/stores/marketplace.test.ts
+++ b/frontend/src/stores/marketplace.test.ts
@@ -47,4 +47,28 @@ describe('marketplace store offers', () => {
inquiryId: 'inquiry-id',
})
})
+
+ it('authenticates CityMarkt with the iFruit password and stores the profile', async () => {
+ const profile = {
+ avatar_media_id: null,
+ avatar_url: null,
+ bio: '',
+ display_name: 'demo',
+ email: 'demo@ifruit.com',
+ exists: true,
+ listing_count: 0,
+ }
+ mockNuiCall.mockResolvedValueOnce({ data: profile, success: true })
+
+ const marketplace = useMarketplaceStore()
+ const response = await marketplace.authenticate('register', 'secret12', 7)
+
+ expect(response).toEqual({ data: profile, success: true })
+ expect(mockNuiCall).toHaveBeenCalledWith('marketplace:auth', {
+ avatarMediaId: 7,
+ mode: 'register',
+ password: 'secret12',
+ })
+ expect(marketplace.profile).toEqual(profile)
+ })
})
diff --git a/frontend/src/stores/marketplace.ts b/frontend/src/stores/marketplace.ts
index f347730..c95563a 100644
--- a/frontend/src/stores/marketplace.ts
+++ b/frontend/src/stores/marketplace.ts
@@ -28,6 +28,19 @@ export const useMarketplaceStore = defineStore('marketplace', {
profile: null as MarketplaceProfile | null,
}),
actions: {
+ async authenticate(
+ mode: 'login' | 'register',
+ password: string,
+ avatarMediaId = 0,
+ ): Promise
> {
+ const response = await nuiCall('marketplace:auth', {
+ avatarMediaId,
+ mode,
+ password,
+ })
+ if (response.success && response.data) this.profile = response.data
+ return response
+ },
async loadProfile(): Promise {
const response = await nuiCall('marketplace:profile')
if (response.success && response.data) this.profile = response.data
diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts
index c6fa122..6cab93c 100644
--- a/frontend/src/stores/phone.ts
+++ b/frontend/src/stores/phone.ts
@@ -3159,18 +3159,29 @@ const defaultLocales: LocaleTree = {
signInTitle: 'Sign in to Sky Cloud',
signInBody: 'Log in to your CityMarkt profile to sell, save or message.',
authEyebrow: 'CityMarkt account',
- authTitle: 'Welcome to CityMarkt',
- authBody:
- 'Your iFruit email is linked automatically. Use your CityMarkt username to continue.',
- login: 'Login',
- register: 'Register',
- authUsername: 'Username',
+ authLoginTitle: 'Welcome back',
+ authRegisterTitle: 'Create your profile',
+ authLoginBody: 'Sign in with the iFruit account linked to this phone.',
+ authRegisterBody:
+ 'Confirm your linked iFruit account to create a CityMarkt profile.',
+ login: 'Sign in',
+ register: 'Create profile',
+ authPassword: 'iFruit password',
+ authPasswordPlaceholder: 'Enter your iFruit password',
+ authPasswordHelp: 'Use 6–64 characters.',
+ authConfirmPassword: 'Repeat password',
+ authConfirmPlaceholder: 'Repeat your iFruit password',
+ passwordsMismatch: 'The passwords do not match.',
authErrors: {
no_ifruit_account: 'Connect a Sky Cloud account in Settings first.',
- invalid_username: 'Enter the username of your CityMarkt profile.',
+ invalid_password: 'Password must be 6–64 characters.',
+ invalid_credentials: 'The iFruit email or password is incorrect.',
+ invalid_profile_image: 'Choose a valid photo from this phone.',
profile_not_found: 'No CityMarkt profile exists for this iFruit email.',
profile_exists:
- 'A CityMarkt profile already exists. Use Login instead.',
+ 'A CityMarkt profile already exists. Use Sign in instead.',
+ rate_limited: 'Too many attempts. Try again in a minute.',
+ default: 'CityMarkt authentication failed.',
},
noMessages: 'No conversations',
noMessagesBody: 'Messages about offers will appear here.',
diff --git a/frontend/src/views/apps/CityMarktApp.vue b/frontend/src/views/apps/CityMarktApp.vue
index 46af0a7..6357919 100644
--- a/frontend/src/views/apps/CityMarktApp.vue
+++ b/frontend/src/views/apps/CityMarktApp.vue
@@ -23,6 +23,7 @@ import {
MessageCircle,
MoreHorizontal,
Pencil,
+ Phone as PhoneIcon,
Rows3,
Search,
Send,
@@ -38,6 +39,7 @@ import {
SkyButton,
SkyGlass,
SkyIcon,
+ SkyLink,
SkyNotification,
SkyNavbar,
SkyAppPage,
@@ -46,7 +48,7 @@ import {
SkyTabButton,
SkyToolbarPane,
} from '@/ui'
-import { computed, onMounted, ref } from 'vue'
+import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
@@ -63,6 +65,7 @@ import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePagesStore } from '@/stores/pages'
import { usePhoneStore } from '@/stores/phone'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
+import { formatPhoneNumber } from '@/utils/phone'
import type {
MarketplaceCategory,
MarketplaceChat,
@@ -128,10 +131,13 @@ const pages = usePagesStore()
const logoutDialogOpen = ref(false)
const authMode = ref<'login' | 'register'>('login')
const authError = ref('')
+const authPassword = ref('')
+const authConfirmPassword = ref('')
const tab = ref('discover')
const screen = ref('main')
const selectedListing = ref(null)
const selectedChat = ref(null)
+const chatMessages = ref(null)
const search = ref('')
const listingLayout = ref<'compact' | 'wide'>('compact')
const category = ref('all')
@@ -558,61 +564,57 @@ async function finishAuthentication(): Promise {
function switchAuthMode(mode: 'login' | 'register'): void {
authMode.value = mode
authError.value = ''
- profileDraft.value.displayName =
- mode === 'login'
- ? (marketplace.profile?.display_name ?? '')
- : (account.email.split('@')[0] ?? '')
+ authPassword.value = ''
+ authConfirmPassword.value = ''
if (mode === 'login') selectedProfilePhoto.value = null
}
async function submitCityMarktAuth(): Promise {
- const username = profileDraft.value.displayName.trim()
authError.value = ''
if (!account.email) {
authError.value = phone.t('Apps.citymarkt.authErrors.no_ifruit_account')
return
}
- if (username.length < 2 || username.length > 40) {
- authError.value = phone.t('Apps.citymarkt.authErrors.invalid_username')
+ if (authPassword.value.length < 6 || authPassword.value.length > 64) {
+ authError.value = phone.t('Apps.citymarkt.authErrors.invalid_password')
+ return
+ }
+ if (
+ authMode.value === 'register' &&
+ authConfirmPassword.value !== authPassword.value
+ ) {
+ authError.value = phone.t('Apps.citymarkt.passwordsMismatch')
return
}
profilePending.value = true
- await marketplace.loadProfile()
- if (authMode.value === 'login') {
- profilePending.value = false
- if (!marketplace.profile?.exists) {
- authError.value = phone.t('Apps.citymarkt.authErrors.profile_not_found')
- return
- }
- if (
- marketplace.profile.display_name.trim().toLocaleLowerCase(phone.lang) !==
- username.toLocaleLowerCase(phone.lang)
- ) {
- authError.value = phone.t('Apps.citymarkt.authErrors.invalid_username')
- return
- }
- } else {
- if (marketplace.profile?.exists) {
- profilePending.value = false
- authError.value = phone.t('Apps.citymarkt.authErrors.profile_exists')
- return
- }
- const response = await marketplace.saveProfile({
- avatarMediaId:
- selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId,
- bio: '',
- displayName: username,
- })
- profilePending.value = false
- if (!response.success) {
- authError.value = phone.t(
- `Apps.citymarkt.errors.${response.error ?? 'default'}`,
- )
- return
- }
+ const response = await marketplace.authenticate(
+ authMode.value,
+ authPassword.value,
+ selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId,
+ )
+ profilePending.value = false
+ if (!response.success) {
+ const knownErrors = [
+ 'invalid_credentials',
+ 'invalid_password',
+ 'invalid_profile_image',
+ 'profile_exists',
+ 'profile_not_found',
+ 'rate_limited',
+ ]
+ authError.value = phone.t(
+ `Apps.citymarkt.authErrors.${
+ response.error && knownErrors.includes(response.error)
+ ? response.error
+ : 'default'
+ }`,
+ )
+ return
}
+ authPassword.value = ''
+ authConfirmPassword.value = ''
appAuth.signIn('citymarkt', account.email)
await finishAuthentication()
}
@@ -839,9 +841,16 @@ async function sendFirstMessage(): Promise {
if (chatResponse.success && chatResponse.data) {
selectedChat.value = chatResponse.data
screen.value = 'chat'
+ await scrollChatToBottom()
}
}
+async function scrollChatToBottom(): Promise {
+ await nextTick()
+ if (!chatMessages.value) return
+ chatMessages.value.scrollTop = chatMessages.value.scrollHeight
+}
+
async function openChat(id: string): Promise {
const response = await marketplace.getInquiry(id)
if (response.success && response.data) {
@@ -849,6 +858,7 @@ async function openChat(id: string): Promise {
message.value = ''
offerPanelOpen.value = false
screen.value = 'chat'
+ await scrollChatToBottom()
await Promise.all([marketplace.loadInquiries(), marketplace.loadCounts()])
}
}
@@ -933,7 +943,10 @@ async function sendChatMessage(): Promise {
const refreshed = await marketplace.getInquiry(
selectedChat.value.inquiry.id,
)
- if (refreshed.data) selectedChat.value = refreshed.data
+ if (refreshed.data) {
+ selectedChat.value = refreshed.data
+ await scrollChatToBottom()
+ }
} else setFeedback(`Apps.citymarkt.errors.${response.error ?? 'default'}`)
}
@@ -1076,7 +1089,20 @@ onMounted(async () => {
: `Apps.citymarkt.tabs.${tab}`,
)
"
- />
+ >
+
+
+
+
+
+
{{ phone.t('Common.loading') }}
@@ -1281,7 +1307,8 @@ onMounted(async () => {
{
>
-
-
- {{ phone.t('Common.signOut') }}
-
@@ -1489,14 +1506,16 @@ onMounted(async () => {
class="citymarkt__detail"
>
-
+ />
{
:next-label="phone.t('Apps.citymarkt.nextPhoto')"
:photo-label="phone.t('Apps.citymarkt.photo')"
/>
-
+
{{ formatPrice(selectedListing) }}
@@ -1575,10 +1600,18 @@ onMounted(async () => {
>
-
- {{ phone.t('Apps.citymarkt.phone') }}:
- {{ selectedListing.phone_number }}
-
+
+
+
+ {{ phone.t('Apps.citymarkt.phone') }}
+ {{
+ formatPhoneNumber(selectedListing.phone_number)
+ }}
+
+
{{
phone.t('Apps.citymarkt.signInToMessage')
}}
+
+ {{
+ phone.t('Apps.citymarkt.contactSeller')
+ }}
+
@@ -1905,6 +1942,15 @@ onMounted(async () => {
{{ label('districts', draft.district) }}
+
+
+ {{ phone.t('Apps.citymarkt.phone') }}
+ {{ formatPhoneNumber(phone.device.sim.number) }}
+
@@ -1925,7 +1971,14 @@ onMounted(async () => {
class="citymarkt__chat"
>