From 011b06784657d529669793b13d29f5f3aea5141e Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:21:01 +0200 Subject: [PATCH] ENH - unify social app accounts and profiles --- frontend/src/App.vue | 6 + .../account/AccountLogoutDialog.vue | 64 + .../src/components/account/AppProfileAuth.vue | 391 +++ .../src/components/account/IfruitAppAuth.vue | 191 ++ .../components/citymarkt/CityMarktAuth.vue | 47 + frontend/src/stores/account.test.ts | 13 + frontend/src/stores/app-auth.test.ts | 53 + frontend/src/stores/app-auth.ts | 78 + frontend/src/stores/crewlink.ts | 27 +- frontend/src/stores/phone-locales.test.ts | 32 + frontend/src/stores/phone.ts | 137 +- frontend/src/types/crewlink.ts | 10 +- frontend/src/views/apps/CityMarktApp.vue | 133 +- frontend/src/views/apps/CrewLinkApp.vue | 2665 +++++++++++++++-- frontend/src/views/apps/FeatherApp.vue | 456 ++- frontend/src/views/apps/LocalPagesApp.vue | 2280 ++++++++++++-- frontend/src/views/apps/SettingsApp.vue | 4 + frontend/testserver/index.cjs | 196 +- sky_phone/config/locales/en.lua | 65 +- sky_phone/source/server/crewlink.lua | 57 +- sky_phone/source/server/db_migrate.lua | 3 + sky_phone/source/server/phone.lua | 1 + sky_phone/sql/install.sql | 5 +- 23 files changed, 6117 insertions(+), 797 deletions(-) create mode 100644 frontend/src/components/account/AccountLogoutDialog.vue create mode 100644 frontend/src/components/account/AppProfileAuth.vue create mode 100644 frontend/src/components/account/IfruitAppAuth.vue create mode 100644 frontend/src/components/citymarkt/CityMarktAuth.vue create mode 100644 frontend/src/stores/app-auth.test.ts create mode 100644 frontend/src/stores/app-auth.ts create mode 100644 frontend/src/stores/phone-locales.test.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c3c8283..a89889c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -32,6 +32,7 @@ import { useBankingStore } from '@/stores/banking' import { useBillingStore } from '@/stores/billing' import { useCompaniesStore } from '@/stores/companies' import { useAccountStore } from '@/stores/account' +import { useAppAuthStore } from '@/stores/app-auth' import { useMailStore } from '@/stores/mail' import { useMessagesStore } from '@/stores/messages' import { useDarkChatStore } from '@/stores/darkchat' @@ -241,6 +242,7 @@ const isDevelopment = import.meta.env.DEV const phone = usePhoneStore() const account = useAccountStore() +const appAuth = useAppAuthStore() const clock = useClockStore() const games = useGamesStore() const calls = useCallsStore() @@ -342,6 +344,10 @@ function hydratePhone(payload: PhoneOpenPayload): void { notifications.hideDevicePreview(payload.device.imei) } account.hydrate(payload.account ?? null) + appAuth.hydrate( + payload.device?.data.appAuth?.payload, + payload.account?.email ?? '', + ) notes.hydrate(payload.notes ?? []) clock.hydrate(payload.device?.data.alarms?.payload) games.hydrate(payload.device?.data.games?.payload) diff --git a/frontend/src/components/account/AccountLogoutDialog.vue b/frontend/src/components/account/AccountLogoutDialog.vue new file mode 100644 index 0000000..55ee3ba --- /dev/null +++ b/frontend/src/components/account/AccountLogoutDialog.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/components/account/AppProfileAuth.vue b/frontend/src/components/account/AppProfileAuth.vue new file mode 100644 index 0000000..c90180b --- /dev/null +++ b/frontend/src/components/account/AppProfileAuth.vue @@ -0,0 +1,391 @@ + + + + + diff --git a/frontend/src/components/account/IfruitAppAuth.vue b/frontend/src/components/account/IfruitAppAuth.vue new file mode 100644 index 0000000..a206ce9 --- /dev/null +++ b/frontend/src/components/account/IfruitAppAuth.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/frontend/src/components/citymarkt/CityMarktAuth.vue b/frontend/src/components/citymarkt/CityMarktAuth.vue new file mode 100644 index 0000000..abd7caa --- /dev/null +++ b/frontend/src/components/citymarkt/CityMarktAuth.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/stores/account.test.ts b/frontend/src/stores/account.test.ts index ed32192..b8eeb62 100644 --- a/frontend/src/stores/account.test.ts +++ b/frontend/src/stores/account.test.ts @@ -58,6 +58,19 @@ describe('account store', () => { expect(account.devices).toEqual(devices) }) + it('clears the linked account after logout', async () => { + mockNuiCall.mockResolvedValueOnce({ success: true }) + + const account = useAccountStore() + account.hydrate({ devices, email: 'alex@ifruit.com' }) + const success = await account.logout() + + expect(success).toBe(true) + expect(account.email).toBe('') + expect(account.devices).toEqual([]) + expect(mockNuiCall).toHaveBeenCalledWith('account:logout') + }) + it('clears account state after a factory reset', async () => { mockNuiCall.mockResolvedValueOnce({ success: true }) diff --git a/frontend/src/stores/app-auth.test.ts b/frontend/src/stores/app-auth.test.ts new file mode 100644 index 0000000..b275e8f --- /dev/null +++ b/frontend/src/stores/app-auth.test.ts @@ -0,0 +1,53 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const saveDeviceNamespace = vi.fn() + +vi.mock('@/stores/phone', () => ({ + usePhoneStore: () => ({ saveDeviceNamespace }), +})) + +import { useAppAuthStore } from '@/stores/app-auth' + +describe('app auth store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + saveDeviceNamespace.mockReset() + }) + + it('keeps each app session independent', () => { + const auth = useAppAuthStore() + auth.hydrate( + { + accountEmail: 'demo@ifruit.com', + signedIn: ['citymarkt', 'feather'], + version: 1, + }, + 'demo@ifruit.com', + ) + + auth.signOut('citymarkt') + + expect(auth.isSignedIn('citymarkt')).toBe(false) + expect(auth.isSignedIn('feather')).toBe(true) + expect(saveDeviceNamespace).toHaveBeenLastCalledWith('appAuth', { + accountEmail: 'demo@ifruit.com', + signedIn: ['feather'], + version: 1, + }) + }) + + it('does not restore sessions belonging to another iFruit account', () => { + const auth = useAppAuthStore() + auth.hydrate( + { + accountEmail: 'old@ifruit.com', + signedIn: ['citymarkt'], + version: 1, + }, + 'new@ifruit.com', + ) + + expect(auth.isSignedIn('citymarkt')).toBe(false) + }) +}) diff --git a/frontend/src/stores/app-auth.ts b/frontend/src/stores/app-auth.ts new file mode 100644 index 0000000..753bdaa --- /dev/null +++ b/frontend/src/stores/app-auth.ts @@ -0,0 +1,78 @@ +import { defineStore } from 'pinia' + +import { usePhoneStore } from '@/stores/phone' + +export const APP_AUTH_IDS = [ + 'citymarkt', + 'local-pages', + 'feather', + 'crewlink', +] as const + +export type AppAuthId = (typeof APP_AUTH_IDS)[number] + +type PersistedAppAuth = { + accountEmail: string + signedIn: AppAuthId[] + version: 1 +} + +function emptySessions(): Record { + return { + citymarkt: false, + 'local-pages': false, + feather: false, + crewlink: false, + } +} + +export const useAppAuthStore = defineStore('app-auth', { + state: () => ({ + accountEmail: '', + sessions: emptySessions(), + }), + actions: { + hydrate(payload: unknown, accountEmail: string): void { + this.accountEmail = accountEmail + this.sessions = emptySessions() + if (!accountEmail || !payload || typeof payload !== 'object') return + + const data = payload as Partial + if ( + data.version !== 1 || + data.accountEmail !== accountEmail || + !Array.isArray(data.signedIn) + ) + return + + for (const appId of data.signedIn) { + if (APP_AUTH_IDS.includes(appId)) this.sessions[appId] = true + } + }, + isSignedIn(appId: AppAuthId): boolean { + return Boolean(this.accountEmail && this.sessions[appId]) + }, + signIn(appId: AppAuthId, accountEmail: string): void { + if (this.accountEmail !== accountEmail) this.sessions = emptySessions() + this.accountEmail = accountEmail + this.sessions[appId] = true + this.persist() + }, + signOut(appId: AppAuthId): void { + this.sessions[appId] = false + this.persist() + }, + clear(): void { + this.accountEmail = '' + this.sessions = emptySessions() + this.persist() + }, + persist(): void { + usePhoneStore().saveDeviceNamespace('appAuth', { + accountEmail: this.accountEmail, + signedIn: APP_AUTH_IDS.filter((appId) => this.sessions[appId]), + version: 1, + } satisfies PersistedAppAuth) + }, + }, +}) diff --git a/frontend/src/stores/crewlink.ts b/frontend/src/stores/crewlink.ts index 0b8ea0e..637a621 100644 --- a/frontend/src/stores/crewlink.ts +++ b/frontend/src/stores/crewlink.ts @@ -62,19 +62,28 @@ export const useCrewLinkStore = defineStore('crewlink', { } return response }, - createProfile(username: string): Promise> { - return this.request('crewlink:create-profile', { username }) + createProfile( + username: string, + avatarMediaId = 0, + ): Promise> { + return this.request('crewlink:create-profile', { + avatarMediaId, + username, + }) }, updateProfile( username: string, mapVisible: boolean, overheadVisible: boolean, + avatarMediaId?: number | null, ): Promise> { - return this.request('crewlink:update-profile', { + const data: Record = { mapVisible, overheadVisible, username, - }) + } + if (avatarMediaId !== undefined) data.avatarMediaId = avatarMediaId + return this.request('crewlink:update-profile', data) }, createGroup( name: string, @@ -107,17 +116,15 @@ export const useCrewLinkStore = defineStore('crewlink', { return this.request('crewlink:join-code', { code }) }, rotateCode(groupId: string): Promise> { - return nuiCall<{ inviteCode: string }>('crewlink:rotate-code', { groupId }) + return nuiCall<{ inviteCode: string }>('crewlink:rotate-code', { + groupId, + }) }, nearby(): Promise> { return nuiCall('crewlink:nearby') }, inviteNearby(targetSource: number): Promise { - return this.request( - 'crewlink:invite-nearby', - { targetSource }, - false, - ) + return this.request('crewlink:invite-nearby', { targetSource }, false) }, respondInvite( invitationId: string, diff --git a/frontend/src/stores/phone-locales.test.ts b/frontend/src/stores/phone-locales.test.ts new file mode 100644 index 0000000..e3ecfb8 --- /dev/null +++ b/frontend/src/stores/phone-locales.test.ts @@ -0,0 +1,32 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { usePhoneStore } from '@/stores/phone' + +describe('phone locale fallback', () => { + beforeEach(() => { + vi.stubGlobal('window', { + matchMedia: vi.fn(() => ({ matches: false })), + }) + setActivePinia(createPinia()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('keeps CityMarkt profile copy translated with a partial server locale', () => { + const phone = usePhoneStore() + phone.open({ locales: { Apps: { citymarkt: { name: 'CityMarkt' } } } }) + + expect(phone.t('Apps.citymarkt.editProfile')).toBe('Edit profile') + expect(phone.t('Apps.citymarkt.profileIntro')).toBe( + 'Your iFruit email stays linked to this profile.', + ) + expect(phone.t('Apps.citymarkt.saveProfile')).toBe('Save profile') + expect(phone.t('Apps.citymarkt.addFavorite')).toBe('Add to favorites') + expect(phone.t('Apps.citymarkt.removeFavorite')).toBe( + 'Remove from favorites', + ) + }) +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 79a9fc9..2b40dea 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -421,6 +421,26 @@ const defaultLocales: LocaleTree = { signInBody: 'CrewLink uses your private iFruit identity to keep groups and roles available across your phones.', openSettings: 'Open iFruit Settings', + authEyebrow: 'Private crew network', + authTitle: 'Welcome to CrewLink', + authBody: + 'Your iFruit email is linked automatically. Use your CrewLink username to continue.', + login: 'Log in', + register: 'Register', + ifruitEmail: 'iFruit address', + gallery: 'Gallery', + camera: 'Camera', + backToLogin: 'Back to CrewLink login', + authErrors: { + no_ifruit_account: 'Sign in to your iFruit account in Settings first.', + invalid_username: 'Use 3–20 letters, numbers, dots, or underscores.', + profile_not_found: 'No CrewLink profile exists for this iFruit email.', + profile_exists: 'This iFruit email already has a CrewLink profile.', + username_taken: 'That CrewLink username is already taken.', + invalid_profile_image: 'Choose a valid photo from this phone.', + rate_limited: 'Too many attempts. Try again shortly.', + request_failed: 'CrewLink could not complete the request.', + }, welcomeEyebrow: 'Your crew. One signal.', welcomeTitle: 'Find your people', welcomeBody: @@ -553,6 +573,9 @@ const defaultLocales: LocaleTree = { externalApiBody: 'Approved scripts may add temporary group pings.', editUsername: 'Edit Username', editUsernameBody: 'Your username is unique across CrewLink.', + editProfile: 'Edit CrewLink Profile', + editProfileBody: 'Change your username and profile photo.', + removeProfilePhoto: 'Remove profile photo', profileSaved: 'CrewLink profile saved.', deleteGroup: 'Delete Group', leaveGroup: 'Leave Group', @@ -592,6 +615,7 @@ const defaultLocales: LocaleTree = { profile_required: 'Create your CrewLink profile first.', invalid_username: 'Use 3–20 letters, numbers, dots, or underscores.', invalid_profile: 'Check your profile details.', + invalid_profile_image: 'Choose a valid photo from this phone.', username_taken: 'That CrewLink username is already taken.', invalid_group: 'Choose a valid group name and signal colour.', group_limit: 'You have reached your group limit.', @@ -1003,14 +1027,16 @@ const defaultLocales: LocaleTree = { done: 'Done', authEyebrow: 'Feather network', authWelcome: 'Welcome to Feather', - authBody: 'One iFruit account. Every conversation, wherever you sign in.', + authBody: + 'Your iFruit email is linked automatically. Use your Feather username to continue.', login: 'Log in', register: 'Register', loginTitle: 'Good to see you again', - loginBody: 'Log in with your iFruit address to continue to Feather.', - registerTitle: 'Create your iFruit account', + loginBody: + 'Use your Feather username. Your iFruit email is linked automatically.', + registerTitle: 'Create your Feather profile', registerBody: - 'Choose an address and secure it with a password. Your Feather profile comes next.', + 'Choose a username and optional profile photo. No password is needed.', email: 'iFruit address', emailPlaceholder: 'yourname', password: 'Password', @@ -1020,23 +1046,25 @@ const defaultLocales: LocaleTree = { showPassword: 'Show password', hidePassword: 'Hide password', loginAction: 'Continue to Feather', - registerAction: 'Create account', + registerAction: 'Create profile', noAccount: 'New to Feather?', haveAccount: 'Already registered?', - registerNow: 'Create an account', + registerNow: 'Create a profile', loginNow: 'Log in', authTrust: - 'Your credentials are verified by the server and this phone is linked to your iFruit account.', + 'This Feather session is separate from your other apps and linked to your iFruit email.', profileStep: 'Step 2 of 2', accountConnected: 'iFruit account connected', authErrors: { - invalid_email: 'Enter a valid 3–32 character iFruit address.', - invalid_password: 'Password must be 6–64 characters.', - password_mismatch: 'The passwords do not match.', - invalid_credentials: 'The iFruit address or password is incorrect.', - email_taken: 'That iFruit address is already registered.', + no_ifruit_account: 'Sign in to your iFruit account in Settings first.', + invalid_handle: 'Use 3–30 letters, numbers or underscores.', + invalid_username: 'That username does not match your Feather profile.', + profile_not_found: 'No Feather profile exists for this iFruit email.', + already_registered: 'This iFruit email already has a Feather profile.', + handle_taken: 'That Feather username is already taken.', + invalid_media: 'Choose a valid photo from this phone.', rate_limited: 'Too many attempts. Try again in a minute.', - default: 'The account request failed. Please try again.', + default: 'Feather could not complete the request. Please try again.', }, welcome: 'Find your voice', welcomeBody: @@ -2537,13 +2565,38 @@ const defaultLocales: LocaleTree = { photos: 'photos', activeListings: 'active listings', signInTitle: 'Sign in to iFruit', - signInBody: - 'Use Settings to sign in before selling, saving or messaging.', + 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', + authErrors: { + no_ifruit_account: 'Connect an iFruit account in Settings first.', + invalid_username: 'Enter the username of your CityMarkt profile.', + profile_not_found: 'No CityMarkt profile exists for this iFruit email.', + profile_exists: + 'A CityMarkt profile already exists. Use Login instead.', + }, noMessages: 'No conversations', noMessagesBody: 'Messages about offers will appear here.', myListings: 'My listings', favorites: 'Favorites', + addFavorite: 'Add to favorites', + removeFavorite: 'Remove from favorites', noProfileListings: 'Nothing here yet', + createProfile: 'Create your CityMarkt profile', + editProfile: 'Edit profile', + profileIntro: 'Your iFruit email stays linked to this profile.', + profileEmail: 'iFruit email', + displayName: 'Display name', + profileBio: 'About you', + saveProfile: 'Save profile', + cancel: 'Cancel', + profileSaved: 'Your profile was saved.', + removeProfilePhoto: 'Remove photo', phone: 'Phone', contactSeller: 'Contact seller', messagePlaceholder: 'Hi, is this still available?', @@ -2683,13 +2736,16 @@ const defaultLocales: LocaleTree = { signInBody: 'Sign in to iFruit in Settings to publish and save posts.', authEyebrow: 'Local Pages account', authWelcome: 'Welcome to Local Pages', - authBody: 'Sign in or create an iFruit account to build your local profile.', + authBody: + 'Your iFruit email is linked automatically. Use your Local Pages username to continue.', login: 'Sign in', register: 'Register', loginTitle: 'Continue with iFruit', - loginBody: 'Your posts, saved items and profile stay linked to your account.', + loginBody: + 'Your posts, saved items and profile stay linked to your account.', registerTitle: 'Create an iFruit account', - registerBody: 'Choose your new iFruit address. Your Local Pages profile comes next.', + registerBody: + 'Choose your new iFruit address. Your Local Pages profile comes next.', authEmail: 'iFruit address', authEmailPlaceholder: 'your.name', authPassword: 'Password', @@ -2705,7 +2761,8 @@ const defaultLocales: LocaleTree = { profileEmail: 'iFruit email', profileHandle: 'Username', profileHandlePlaceholder: 'your.name', - profileHandleHint: 'Use 3–24 lowercase letters, numbers, dots or underscores.', + profileHandleHint: + 'Use 3–24 lowercase letters, numbers, dots or underscores.', profileBio: 'Bio', profileBioPlaceholder: 'Tell the city a little about yourself...', profilePhoto: 'Profile photo', @@ -2751,13 +2808,17 @@ const defaultLocales: LocaleTree = { 'Review the listing and publish it when you are ready.', cityMarktPhotosHint: 'The CityMarkt listing photos will be included.', authErrors: { - invalid_email: 'Enter a valid 3–32 character iFruit address.', - invalid_password: 'Use a password between 6 and 64 characters.', - invalid_credentials: 'The iFruit address or password is incorrect.', - email_taken: 'This iFruit address is already registered.', - password_mismatch: 'The passwords do not match.', + no_ifruit_account: 'Sign in to your iFruit account in Settings first.', + invalid_username: + 'Use 3–24 lowercase letters, numbers, dots or underscores.', + profile_not_found: + 'No Local Pages profile exists for this iFruit email.', + profile_exists: 'This iFruit email already has a Local Pages profile.', + invalid_profile: 'Check your Local Pages username.', + invalid_profile_image: 'Choose a valid photo from this phone.', + profile_handle_taken: 'This Local Pages username is already taken.', rate_limited: 'Too many attempts. Try again shortly.', - default: 'The iFruit account request failed.', + default: 'Local Pages could not complete the request.', }, errors: { profile_required: 'Create your Local Pages profile first.', @@ -3469,6 +3530,32 @@ const defaultLocales: LocaleTree = { send: 'Send', start: 'Start', stop: 'Stop', + signOut: 'Sign Out', + signingOut: 'Signing Out...', + signOutTitle: 'Sign out of {app}?', + signOutBody: + 'You will only be signed out of {app}. Your other iFruit apps stay signed in.', + signOutFailed: 'Could not sign out. Please try again.', + appAuth: { + eyebrow: 'iFruit account', + title: 'Continue to {app}', + body: 'Use your iFruit email and password. This login applies only to {app}.', + login: 'Login', + register: 'Register', + email: 'iFruit email', + password: 'Password', + confirm: 'Confirm password', + loginAction: 'Log in', + registerAction: 'Create account', + errors: { + invalid_email: 'Enter a valid iFruit email.', + invalid_password: 'Password must be 6–64 characters.', + invalid_credentials: 'Email or password is incorrect.', + email_taken: 'That iFruit email is already registered.', + rate_limited: 'Too many attempts. Try again in a minute.', + default: 'The account request failed.', + }, + }, use: 'Use', }, Notifications: { diff --git a/frontend/src/types/crewlink.ts b/frontend/src/types/crewlink.ts index ae54bda..f27f0f5 100644 --- a/frontend/src/types/crewlink.ts +++ b/frontend/src/types/crewlink.ts @@ -15,15 +15,12 @@ export type CrewLinkColour = | 'green' | 'rose' -export type CrewLinkPingType = - | 'meeting' - | 'danger' - | 'help' - | 'target' - | 'info' +export type CrewLinkPingType = 'meeting' | 'danger' | 'help' | 'target' | 'info' export type CrewLinkProfile = { activeGroupId: string | null + avatarMediaId: number | null + avatarUrl: string | null id: string mapVisible: boolean overheadVisible: boolean @@ -31,6 +28,7 @@ export type CrewLinkProfile = { } export type CrewLinkMember = { + avatarUrl?: string | null coords?: MapPoint & { z: number } id: string joinedAt: number diff --git a/frontend/src/views/apps/CityMarktApp.vue b/frontend/src/views/apps/CityMarktApp.vue index 6a4c321..f65bbfa 100644 --- a/frontend/src/views/apps/CityMarktApp.vue +++ b/frontend/src/views/apps/CityMarktApp.vue @@ -18,6 +18,7 @@ import { Inbox, Laptop, LayoutGrid, + LogOut, MapPin, MessageCircle, MoreHorizontal, @@ -50,7 +51,10 @@ import { useRoute, useRouter } from 'vue-router' import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue' import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue' import CityMarktOfferCard from '@/components/citymarkt/CityMarktOfferCard.vue' +import CityMarktAuth from '@/components/citymarkt/CityMarktAuth.vue' +import AccountLogoutDialog from '@/components/account/AccountLogoutDialog.vue' import { useAccountStore } from '@/stores/account' +import { useAppAuthStore } from '@/stores/app-auth' import { useAppStoreStore } from '@/stores/app-store' import { useEasyShareStore } from '@/stores/easyshare' import { useMarketplaceStore } from '@/stores/marketplace' @@ -105,7 +109,10 @@ type MediaContext = { photos: SelectedPhoto[] sellStep: number } -type ProfileMediaContext = { draft: MarketplaceProfileDraft } +type ProfileMediaContext = { + authMode?: 'login' | 'register' + draft: MarketplaceProfileDraft +} const phone = usePhoneStore() const detailActionColors = computed(() => ({ @@ -115,11 +122,15 @@ const detailActionColors = computed(() => ({ const route = useRoute() const router = useRouter() const account = useAccountStore() +const appAuth = useAppAuthStore() const appStore = useAppStoreStore() const easyShare = useEasyShareStore() const marketplace = useMarketplaceStore() const messageMedia = useMessageMediaStore() const pages = usePagesStore() +const logoutDialogOpen = ref(false) +const authMode = ref<'login' | 'register'>('login') +const authError = ref('') const tab = ref('discover') const screen = ref('main') const selectedListing = ref(null) @@ -239,7 +250,7 @@ const tabs = [ { icon: UserRound, id: 'profile' }, ] as const -const isAuthenticated = computed(() => account.email !== '') +const isAuthenticated = computed(() => appAuth.isSignedIn('citymarkt')) const localPagesInstalled = computed( () => appStore.isInstalled('local-pages') && @@ -529,6 +540,80 @@ async function selectTab(next: Tab): Promise { } } +async function finishAuthentication(): Promise { + await marketplace.loadProfile() + syncProfileDraft() + profileEditing.value = !marketplace.profile?.exists + await Promise.all([ + marketplace.loadOwn(), + marketplace.load({ favorites: true }), + ]) + tab.value = 'profile' + screen.value = 'main' +} + +function switchAuthMode(mode: 'login' | 'register'): void { + authMode.value = mode + authError.value = '' + profileDraft.value.displayName = + mode === 'login' + ? (marketplace.profile?.display_name ?? '') + : account.email.split('@')[0] ?? '' + 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') + 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 + } + } + + appAuth.signIn('citymarkt', account.email) + await finishAuthentication() +} + async function openListing( item: Pick, ): Promise { @@ -578,9 +663,12 @@ function openProfileMedia(app: 'camera' | 'photos'): void { 'photo', '/apps/citymarkt?profileEdit=1', 1, - { draft: { ...profileDraft.value } } satisfies ProfileMediaContext, + { + authMode: isAuthenticated.value ? undefined : authMode.value, + draft: { ...profileDraft.value }, + } satisfies ProfileMediaContext, ) - void router.push(app === 'photos' ? '/apps/photos?picker=1' : '/apps/camera?picker=1') + void router.push(`/apps/${app}?mediaAttachment=photo`) } function removeProfilePhoto(): void { @@ -905,11 +993,13 @@ onMounted(async () => { } if (profileSelection?.context) { profileDraft.value = profileSelection.context.draft + if (profileSelection.context.authMode) + authMode.value = profileSelection.context.authMode if (profileSelection.media[0]) { selectedProfilePhoto.value = profileSelection.media[0] profileDraft.value.avatarMediaId = profileSelection.media[0].id } - profileEditing.value = true + profileEditing.value = isAuthenticated.value tab.value = 'profile' screen.value = 'main' } @@ -932,6 +1022,8 @@ onMounted(async () => { } await marketplace.loadCounts() } else { + await marketplace.loadProfile() + switchAuthMode(marketplace.profile?.exists ? 'login' : 'register') tab.value = 'profile' screen.value = 'main' } @@ -1127,6 +1219,9 @@ onMounted(async () => {

{{ phone.t('Apps.citymarkt.signInTitle') }}

{{ phone.t('Apps.citymarkt.signInBody') }}

+ + {{ phone.t('Apps.citymarkt.login') }} +
{{ @@ -1156,9 +1251,17 @@ onMounted(async () => { @@ -1849,6 +1956,11 @@ onMounted(async () => { +
{{ feedback }} @@ -2503,6 +2615,11 @@ onMounted(async () => { .citymarkt__profile-actions button:disabled { opacity: .4; } +.citymarkt__logout { + width: 100%; + margin-top: 12px; + color: #ff796f; +} :global(.citymarkt--light) .citymarkt__profile-editor { border-color: #00000012; } diff --git a/frontend/src/views/apps/CrewLinkApp.vue b/frontend/src/views/apps/CrewLinkApp.vue index 9680cd4..d54e5bd 100644 --- a/frontend/src/views/apps/CrewLinkApp.vue +++ b/frontend/src/views/apps/CrewLinkApp.vue @@ -23,6 +23,7 @@ import { } from 'konsta/vue' import { AlertTriangle, + Camera, Check, CircleDot, Copy, @@ -32,7 +33,9 @@ import { EyeOff, Flag, Info, + Images, LocateFixed, + LogOut, Map as MapIcon, MapPin, Navigation, @@ -52,13 +55,7 @@ import { X, Zap, } from 'lucide-vue-next' -import { - computed, - nextTick, - onBeforeUnmount, - onMounted, - ref, -} from 'vue' +import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { @@ -69,9 +66,15 @@ import { defaultMapWorldToPercent, type MapPoint, } from '@/features/map/defaultMapGeometry' +import AccountLogoutDialog from '@/components/account/AccountLogoutDialog.vue' +import AppProfileAuth from '@/components/account/AppProfileAuth.vue' +import { useAccountStore } from '@/stores/account' +import { useAppAuthStore } from '@/stores/app-auth' import { useCrewLinkStore } from '@/stores/crewlink' import { useEasyShareStore } from '@/stores/easyshare' +import { useMessageMediaStore } from '@/stores/messageMedia' import { usePhoneStore } from '@/stores/phone' +import type { PhoneMedia } from '@/types/media' import type { CrewLinkColour, CrewLinkMember, @@ -92,11 +95,24 @@ type CrewLinkSheet = | 'nearby' | 'ping' | 'edit-group' + | 'edit-profile' | 'member' | null +type AuthMediaContext = { + mode: 'login' | 'register' + selectedPhoto: PhoneMedia | null + username: string +} +type ProfileMediaContext = { + selectedPhoto: PhoneMedia | null + username: string +} const phone = usePhoneStore() +const account = useAccountStore() +const appAuth = useAppAuthStore() const crew = useCrewLinkStore() +const messageMedia = useMessageMediaStore() const route = useRoute() const router = useRouter() const mainlandMapUrl = `${import.meta.env.BASE_URL}img/maps/gtav-map.svg` @@ -104,6 +120,13 @@ const cayoMapUrl = `${import.meta.env.BASE_URL}img/maps/cayo-perico.svg` const activeTab = ref('map') const sheet = ref(null) const username = ref('') +const authMode = ref<'login' | 'register'>('login') +const authUsername = ref('') +const authProfilePhoto = ref(null) +const authPending = ref(false) +const authError = ref('') +const selectedProfilePhoto = ref(null) +const profileAvatarRemoved = ref(false) const groupName = ref('') const groupColour = ref('cyan') const inviteCode = ref('') @@ -121,10 +144,15 @@ const nearbyPlayers = ref([]) const selectedMember = ref(null) const selectedPing = ref(null) const formError = ref('') +const logoutDialogOpen = ref(false) const toastText = ref('') -const pendingGroupSetting = ref<'allowMemberPings' | 'overheadAllowed' | null>(null) +const pendingGroupSetting = ref<'allowMemberPings' | 'overheadAllowed' | null>( + null, +) const pendingVisibility = ref<'mapVisible' | 'overheadVisible' | null>(null) -const confirmAction = ref<'delete-group' | 'leave-group' | 'remove-member' | 'transfer-owner' | null>(null) +const confirmAction = ref< + 'delete-group' | 'leave-group' | 'remove-member' | 'transfer-owner' | null +>(null) const zoom = ref(1.45) const pan = ref({ x: 0, y: 0 }) const viewportRef = ref(null) @@ -185,18 +213,21 @@ const ownMember = computed(() => const onlineMembers = computed( () => activeGroup.value?.members.filter((member) => member.online) ?? [], ) -const visibleMapMembers = computed( - () => onlineMembers.value.filter((member) => Boolean(member.coords)), +const visibleMapMembers = computed(() => + onlineMembers.value.filter((member) => Boolean(member.coords)), ) const canCoordinate = computed( - () => roleLevels[activeGroup.value?.role ?? 'guest'] >= roleLevels.coordinator, + () => + roleLevels[activeGroup.value?.role ?? 'guest'] >= roleLevels.coordinator, ) const canModerate = computed( () => roleLevels[activeGroup.value?.role ?? 'guest'] >= roleLevels.moderator, ) const canPing = computed( - () => - canModerate.value || Boolean(activeGroup.value?.allowMemberPings), + () => canModerate.value || Boolean(activeGroup.value?.allowMemberPings), +) +const authUsernameValid = computed(() => + /^[A-Za-z0-9][A-Za-z0-9._]{1,18}[A-Za-z0-9]$/.test(authUsername.value.trim()), ) const canvasStyle = computed(() => ({ aspectRatio: String( @@ -223,11 +254,17 @@ const mapCenterCoords = computed(() => { return defaultMapPercentToWorld({ x: Math.min( 1, - Math.max(0, (viewport.left + viewport.width / 2 - canvas.left) / canvas.width), + Math.max( + 0, + (viewport.left + viewport.width / 2 - canvas.left) / canvas.width, + ), ), y: Math.min( 1, - Math.max(0, (viewport.top + viewport.height / 2 - canvas.top) / canvas.height), + Math.max( + 0, + (viewport.top + viewport.height / 2 - canvas.top) / canvas.height, + ), ), }) }) @@ -252,6 +289,107 @@ function showToast(message: string): void { }, 2400) } +function switchAuthMode(mode: 'login' | 'register'): void { + authMode.value = mode + authProfilePhoto.value = null + authUsername.value = + mode === 'register' + ? (account.email.split('@')[0] ?? '') + .replace(/[^a-z0-9._]/gi, '_') + .slice(0, 20) + : '' + authError.value = '' +} + +function authErrorText(code?: string): string { + const known = [ + 'invalid_profile_image', + 'invalid_username', + 'no_ifruit_account', + 'profile_exists', + 'profile_not_found', + 'rate_limited', + 'username_taken', + ] + return t( + `authErrors.${code && known.includes(code) ? code : 'request_failed'}`, + ) +} + +async function submitAuthentication(): Promise { + authError.value = '' + if (!account.email) { + authError.value = authErrorText('no_ifruit_account') + return + } + if (!authUsernameValid.value) { + authError.value = authErrorText('invalid_username') + return + } + + const submittedUsername = authUsername.value.trim() + authPending.value = true + const loaded = await crew.bootstrap() + if (!loaded) { + authPending.value = false + authError.value = authErrorText(crew.error) + return + } + + if (authMode.value === 'login') { + authPending.value = false + if (!crew.profile) { + authError.value = authErrorText('profile_not_found') + return + } + if ( + crew.profile.username.toLowerCase() !== submittedUsername.toLowerCase() + ) { + authError.value = authErrorText('invalid_username') + return + } + } else { + if (crew.profile) { + authPending.value = false + authError.value = authErrorText('profile_exists') + return + } + const response = await crew.createProfile( + submittedUsername, + authProfilePhoto.value?.id ?? 0, + ) + authPending.value = false + if (!response.success) { + authError.value = authErrorText(response.error) + return + } + } + + appAuth.signIn('crewlink', account.email) + username.value = crew.profile?.username ?? submittedUsername + authUsername.value = '' + authProfilePhoto.value = null + openSharedInvite() +} + +function openAuthMedia(app: 'camera' | 'photos'): void { + messageMedia.begin( + 'crewlink:auth-avatar', + 'photo', + '/apps/crewlink?auth=register', + 1, + { + mode: authMode.value, + selectedPhoto: authProfilePhoto.value, + username: authUsername.value, + } satisfies AuthMediaContext, + ) + void router.push({ + path: `/apps/${app}`, + query: { mediaAttachment: 'photo' }, + }) +} + function shareProfile(): void { const profile = crew.profile if (!profile) return @@ -303,7 +441,10 @@ function updateValue( } function colourValue(colour: CrewLinkColour): string { - return colours.find((candidate) => candidate.id === colour)?.value ?? colours[0].value + return ( + colours.find((candidate) => candidate.id === colour)?.value ?? + colours[0].value + ) } function roleLabel(role: CrewLinkRole): string { @@ -314,7 +455,10 @@ function memberInitials(member: CrewLinkMember): string { return member.username.slice(0, 2).toUpperCase() } -function markerStyle(coords: MapPoint, offset = '-50%'): Record { +function markerStyle( + coords: MapPoint, + offset = '-50%', +): Record { const point = defaultMapWorldToPercent(coords) return { left: `${point.x * 100}%`, @@ -331,7 +475,8 @@ function memberStatus(member: CrewLinkMember): string { function expiresIn(timestamp: number): string { const seconds = Math.max(0, Math.ceil((timestamp - Date.now()) / 1000)) - if (seconds >= 60) return t('expiresMinutes', { count: String(Math.ceil(seconds / 60)) }) + if (seconds >= 60) + return t('expiresMinutes', { count: String(Math.ceil(seconds / 60)) }) return t('expiresSeconds', { count: String(seconds) }) } @@ -357,9 +502,42 @@ function closeSheet(): void { if (crew.isLoading) return sheet.value = null selectedMember.value = null + selectedProfilePhoto.value = null + profileAvatarRemoved.value = false formError.value = '' } +function editOwnProfile(): void { + if (!crew.profile) return + username.value = crew.profile.username + selectedProfilePhoto.value = null + profileAvatarRemoved.value = false + formError.value = '' + sheet.value = 'edit-profile' +} + +function openProfileMedia(app: 'camera' | 'photos'): void { + messageMedia.begin( + 'crewlink:profile-avatar', + 'photo', + '/apps/crewlink?profileEdit=1', + 1, + { + selectedPhoto: selectedProfilePhoto.value, + username: username.value, + } satisfies ProfileMediaContext, + ) + void router.push({ + path: `/apps/${app}`, + query: { mediaAttachment: 'photo' }, + }) +} + +function removeProfilePhoto(): void { + selectedProfilePhoto.value = null + profileAvatarRemoved.value = true +} + async function createProfile(): Promise { const response = await crew.createProfile(username.value.trim()) if (!response.success) { @@ -371,7 +549,10 @@ async function createProfile(): Promise { } async function createGroup(): Promise { - const response = await crew.createGroup(groupName.value.trim(), groupColour.value) + const response = await crew.createGroup( + groupName.value.trim(), + groupColour.value, + ) if (!response.success) { formError.value = errorText(response.error) return @@ -405,13 +586,20 @@ async function switchGroup(groupId: string): Promise { async function saveProfile(): Promise { if (!crew.profile) return + const avatarMediaId = + selectedProfilePhoto.value?.id ?? + (profileAvatarRemoved.value ? 0 : crew.profile.avatarMediaId) const response = await crew.updateProfile( username.value.trim(), crew.profile.mapVisible, crew.profile.overheadVisible, + avatarMediaId, ) if (!response.success) formError.value = errorText(response.error) - else showToast(t('profileSaved')) + else { + closeSheet() + showToast(t('profileSaved')) + } } async function updateVisibility( @@ -479,9 +667,7 @@ function togglePingAtMapCenter(): void { function copyInviteCode(): void { if (!activeGroup.value?.inviteCode) return showToast( - copyText(activeGroup.value.inviteCode) - ? t('codeCopied') - : errorText(), + copyText(activeGroup.value.inviteCode) ? t('codeCopied') : errorText(), ) } @@ -553,12 +739,20 @@ async function performConfirmedAction(): Promise { confirmAction.value = null if (!activeGroup.value) return let response - if (action === 'delete-group') response = await crew.deleteGroup(activeGroup.value.id) - else if (action === 'leave-group') response = await crew.leave(activeGroup.value.id) + if (action === 'delete-group') + response = await crew.deleteGroup(activeGroup.value.id) + else if (action === 'leave-group') + response = await crew.leave(activeGroup.value.id) else if (action === 'remove-member' && selectedMember.value) { - response = await crew.removeMember(activeGroup.value.id, selectedMember.value.id) + response = await crew.removeMember( + activeGroup.value.id, + selectedMember.value.id, + ) } else if (action === 'transfer-owner' && selectedMember.value) { - response = await crew.transferOwner(activeGroup.value.id, selectedMember.value.id) + response = await crew.transferOwner( + activeGroup.value.id, + selectedMember.value.id, + ) } if (!response?.success) showToast(errorText(response?.error)) else { @@ -599,11 +793,27 @@ async function removePing(ping: CrewLinkPing): Promise { } } -async function routeTo(coords: { x: number; y: number; z: number }): Promise { +async function routeTo(coords: { + x: number + y: number + z: number +}): Promise { const response = await nuiCall('map:setWaypoint', { coords }) showToast(t(response.success ? 'routeSet' : 'errors.request_failed')) } +function routeToSelectedMember(): void { + if (!selectedMember.value?.coords) return + void routeTo(selectedMember.value.coords) + selectedMember.value = null +} + +function routeToSelectedPing(): void { + if (!selectedPing.value) return + void routeTo(selectedPing.value.coords) + selectedPing.value = null +} + function centerOn(coords: MapPoint): void { const viewport = viewportRef.value?.getBoundingClientRect() const canvas = canvasRef.value @@ -658,7 +868,10 @@ function centerOwnLocation(): void { } function changeZoom(direction: -1 | 1): void { - zoom.value = Math.max(0.85, Math.min(8, zoom.value * (direction > 0 ? 1.32 : 0.76))) + zoom.value = Math.max( + 0.85, + Math.min(8, zoom.value * (direction > 0 ? 1.32 : 0.76)), + ) } function onPointerDown(event: PointerEvent): void { @@ -700,8 +913,30 @@ function onCrewLinkMessage(event: MessageEvent): void { } onMounted(async () => { - await crew.bootstrap() + const authSelection = messageMedia.consumeMany( + 'crewlink:auth-avatar', + ) + const profileSelection = messageMedia.consumeMany( + 'crewlink:profile-avatar', + ) + if (authSelection) { + authMode.value = authSelection.context?.mode ?? 'register' + authUsername.value = authSelection.context?.username ?? '' + authProfilePhoto.value = + authSelection.media[0] ?? authSelection.context?.selectedPhoto ?? null + } + if (appAuth.isSignedIn('crewlink')) await crew.bootstrap() username.value = crew.profile?.username ?? '' + if (profileSelection && crew.profile) { + username.value = profileSelection.context?.username ?? crew.profile.username + selectedProfilePhoto.value = + profileSelection.media[0] ?? + profileSelection.context?.selectedPhoto ?? + null + profileAvatarRemoved.value = false + activeTab.value = 'profile' + sheet.value = 'edit-profile' + } openSharedInvite() await nextTick() fitOnlineMembers() @@ -719,7 +954,36 @@ onBeforeUnmount(() => {