From 05444c84ec909b9da23f1da1250a95719d4ac7e5 Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:46:33 +0200 Subject: [PATCH] ENH - refine CrewLink layouts and authentication --- .../src/components/account/AppProfileAuth.vue | 320 +++++++++++++++++- frontend/src/stores/crewlink.test.ts | 23 ++ frontend/src/stores/crewlink.ts | 19 +- frontend/src/stores/phone.ts | 6 +- frontend/src/types/crewlink.ts | 1 + frontend/src/views/apps/CrewLinkApp.vue | 223 ++++++------ frontend/testserver/index.cjs | 78 +++-- frontend/testserver/smoke.cjs | 8 +- sky_phone/config/config.lua | 3 + sky_phone/config/locales/en.lua | 6 +- sky_phone/source/client/main.lua | 4 +- sky_phone/source/server/crewlink.lua | 173 ++++++++-- sky_phone/source/server/db_migrate.lua | 33 ++ sky_phone/source/server/testdata.lua | 13 + sky_phone/sql/install.sql | 21 ++ 15 files changed, 743 insertions(+), 188 deletions(-) diff --git a/frontend/src/components/account/AppProfileAuth.vue b/frontend/src/components/account/AppProfileAuth.vue index 3cac30a..fe7e387 100644 --- a/frontend/src/components/account/AppProfileAuth.vue +++ b/frontend/src/components/account/AppProfileAuth.vue @@ -32,17 +32,27 @@ const props = withDefaults( maxUsernameLength?: number minUsernameLength?: number mode: 'login' | 'register' + password?: string + passwordLabel?: string + passwordPlaceholder?: string pending: boolean registerLabel: string + requirePassword?: boolean title: string username: string usernameLabel: string usernamePlaceholder?: string + variant?: 'default' | 'centered' }>(), { maxUsernameLength: 40, minUsernameLength: 2, + password: '', + passwordLabel: 'Password', + passwordPlaceholder: '', + requirePassword: false, usernamePlaceholder: '', + variant: 'default', }, ) const emit = defineEmits<{ @@ -50,21 +60,30 @@ const emit = defineEmits<{ gallery: [] submit: [] 'update:mode': [value: 'login' | 'register'] + 'update:password': [value: string] 'update:username': [value: string] }>() const canSubmit = computed(() => { const length = props.username.trim().length + const validUsername = + length >= props.minUsernameLength && length <= props.maxUsernameLength return Boolean( props.email && - length >= props.minUsernameLength && - length <= props.maxUsernameLength, + (props.mode === 'login' && props.requirePassword + ? true + : validUsername) && + (!props.requirePassword || + (props.password.length >= 8 && props.password.length <= 72)), ) }) - + @@ -75,7 +94,39 @@ const canSubmit = computed(() => { - + + + {{ loginLabel }} + + + {{ registerLabel }} + + + + - + + + + {{ usernameLabel }} + + + + + + + + {{ passwordLabel }} + + + + + { .app-profile-auth__identity > svg { color: var(--muted, #9ba4aa); } +.app-profile-auth__username-field { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + align-items: center; + gap: 9px; + margin-bottom: 11px; + padding: 9px 11px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 15px; + background: rgba(255, 255, 255, 0.045); + text-align: left; +} +.app-profile-auth__password-field { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + align-items: center; + gap: 9px; + margin-bottom: 11px; + padding: 9px 11px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 15px; + background: rgba(255, 255, 255, 0.045); + text-align: left; +} +.app-profile-auth__password-field > span { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 11px; + color: var(--auth-accent, #ffd63e); + background: color-mix(in srgb, var(--auth-accent, #ffd63e) 13%, transparent); +} +.app-profile-auth__password-field div { + min-width: 0; +} +.app-profile-auth__password-field small { + display: block; + margin-bottom: 1px; + color: var(--muted, #9ba4aa); + font-size: 9px; +} +.app-profile-auth__password-field input { + box-sizing: border-box; + width: 100%; + min-width: 0; + padding: 0; + border: 0; + outline: 0; + color: inherit; + background: transparent; + font: inherit; + font-size: 12px; + font-weight: 700; + line-height: 18px; +} +.app-profile-auth__password-field input::placeholder { + color: var(--muted, #9ba4aa); + opacity: 0.72; +} +.app-profile-auth__username-field > span { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 11px; + color: var(--auth-accent, #ffd63e); + background: color-mix(in srgb, var(--auth-accent, #ffd63e) 13%, transparent); +} +.app-profile-auth__username-field div { + min-width: 0; +} +.app-profile-auth__username-field small { + display: block; + margin-bottom: 1px; + color: var(--muted, #9ba4aa); + font-size: 9px; +} +.app-profile-auth__username-field input { + box-sizing: border-box; + width: 100%; + min-width: 0; + padding: 0; + border: 0; + outline: 0; + color: inherit; + background: transparent; + font: inherit; + font-size: 12px; + font-weight: 700; + line-height: 18px; +} +.app-profile-auth__username-field input::placeholder { + color: var(--muted, #9ba4aa); + opacity: 0.72; +} .app-profile-auth__fields { margin-top: 0; margin-right: 0; @@ -427,4 +631,110 @@ const canSubmit = computed(() => { .app-profile-auth__submit:disabled { opacity: 0.46; } +.app-profile-auth--centered { + max-width: 340px; + padding: 0; +} +.app-profile-auth--centered .app-profile-auth__hero { + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; + margin: 0 18px 18px; + text-align: center; +} +.app-profile-auth--centered .app-profile-auth__mark { + width: 54px; + height: 54px; + margin-bottom: 3px; + border-radius: 18px; +} +.app-profile-auth--centered .app-profile-auth__hero h2 { + margin: 0; + font-size: 24px; +} +.app-profile-auth--centered .app-profile-auth__hero p { + max-width: 285px; + margin: 0; + font-size: 12px; + line-height: 1.45; +} +.app-profile-auth--centered .app-profile-auth__card { + box-sizing: border-box; + width: 100%; + margin-inline: auto; + padding: 14px; + border-radius: 28px; +} +.app-profile-auth--centered .app-profile-auth__mode { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; + padding: 4px; + border-radius: 15px; +} +.app-profile-auth__mode-choice { + min-width: 0; + min-height: 40px; + padding: 0 12px; + border: 0; + border-radius: 11px; + display: grid; + place-items: center; + color: #b8c5ce; + background: transparent; + font: inherit; + font-size: 14px; + font-weight: 650; + line-height: 1; + cursor: pointer; + transition: + color 160ms ease, + background 160ms ease, + box-shadow 160ms ease; +} +.app-profile-auth__mode-choice--active { + color: #fff; + background: var(--auth-accent, #ffd63e); + box-shadow: 0 6px 16px + color-mix(in srgb, var(--auth-accent, #ffd63e) 28%, transparent); + font-weight: 750; +} +.app-profile-auth--centered .app-profile-auth__identity, +.app-profile-auth--centered .app-profile-auth__username-field, +.app-profile-auth--centered .app-profile-auth__password-field { + grid-template-columns: 38px minmax(0, 1fr) 18px; + min-height: 58px; + margin-bottom: 10px; + padding: 8px 12px; + border-radius: 17px; +} +.app-profile-auth--centered .app-profile-auth__username-field { + grid-template-columns: 38px minmax(0, 1fr); +} +.app-profile-auth--centered .app-profile-auth__password-field { + grid-template-columns: 38px minmax(0, 1fr); +} +.app-profile-auth--centered .app-profile-auth__identity > span, +.app-profile-auth--centered .app-profile-auth__username-field > span, +.app-profile-auth--centered .app-profile-auth__password-field > span { + width: 38px; + height: 38px; + border-radius: 12px; +} +.app-profile-auth--centered .app-profile-auth__identity small, +.app-profile-auth--centered .app-profile-auth__username-field small, +.app-profile-auth--centered .app-profile-auth__password-field small { + font-size: 10px; +} +.app-profile-auth--centered .app-profile-auth__identity strong, +.app-profile-auth--centered .app-profile-auth__username-field input, +.app-profile-auth--centered .app-profile-auth__password-field input { + font-size: 14px; + line-height: 20px; +} +.app-profile-auth--centered .app-profile-auth__submit { + min-height: 48px; + border-radius: 16px; +} diff --git a/frontend/src/stores/crewlink.test.ts b/frontend/src/stores/crewlink.test.ts index 64901c6..1bc7bf3 100644 --- a/frontend/src/stores/crewlink.test.ts +++ b/frontend/src/stores/crewlink.test.ts @@ -58,6 +58,29 @@ describe('CrewLink store', () => { expect(store.error).toBe('invalid_code') }) + it('sends only the password when logging in', async () => { + vi.mocked(nuiCall).mockResolvedValue({ + success: false, + error: 'invalid_credentials', + }) + const store = useCrewLinkStore() + await store.login('CrewLink123!') + expect(nuiCall).toHaveBeenCalledWith('crewlink:login', { + password: 'CrewLink123!', + }) + }) + + it('sends username, password, and avatar when registering', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: true }) + const store = useCrewLinkStore() + await store.register('Skyline', 'CrewLink123!', 42) + expect(nuiCall).toHaveBeenCalledWith('crewlink:register', { + avatarMediaId: 42, + password: 'CrewLink123!', + username: 'Skyline', + }) + }) + it('applies live members without replacing group metadata', async () => { const store = useCrewLinkStore() store.activeGroup = { diff --git a/frontend/src/stores/crewlink.ts b/frontend/src/stores/crewlink.ts index 637a621..dcd06d0 100644 --- a/frontend/src/stores/crewlink.ts +++ b/frontend/src/stores/crewlink.ts @@ -15,6 +15,7 @@ import { nuiCall, type NuiResponse } from '@/utils/nui' export const useCrewLinkStore = defineStore('crewlink', { state: () => ({ activeGroup: null as CrewLinkGroup | null, + authenticated: false, error: '', groups: [] as CrewLinkBootstrap['groups'], invitations: [] as CrewLinkBootstrap['invitations'], @@ -24,6 +25,7 @@ export const useCrewLinkStore = defineStore('crewlink', { }), actions: { applyBootstrap(data: CrewLinkBootstrap): void { + this.authenticated = data.authenticated ?? Boolean(data.profile) this.profile = data.profile this.groups = data.groups ?? [] this.activeGroup = data.activeGroup ?? null @@ -62,15 +64,28 @@ export const useCrewLinkStore = defineStore('crewlink', { } return response }, - createProfile( + login(password: string): Promise> { + return this.request('crewlink:login', { password }) + }, + register( username: string, + password: string, avatarMediaId = 0, ): Promise> { - return this.request('crewlink:create-profile', { + return this.request('crewlink:register', { avatarMediaId, + password, username, }) }, + logout(): Promise { + this.authenticated = false + this.profile = null + this.groups = [] + this.activeGroup = null + this.invitations = [] + return nuiCall('crewlink:logout') + }, updateProfile( username: string, mapVisible: boolean, diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 85bd05a..51c4c03 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -641,10 +641,12 @@ const defaultLocales: LocaleTree = { authEyebrow: 'Private crew network', authTitle: 'Welcome to CrewLink', authBody: - 'Your iFruit email is linked automatically. Use your CrewLink username to continue.', + 'Your iFruit email is linked automatically. Enter your password to continue.', login: 'Log in', register: 'Register', ifruitEmail: 'iFruit address', + password: 'Password', + passwordPlaceholder: '8–72 characters', gallery: 'Photos', camera: 'Camera', backToLogin: 'Back to CrewLink login', @@ -652,6 +654,8 @@ const defaultLocales: LocaleTree = { no_ifruit_account: 'Sign in to your Sky Cloud account in Settings first.', invalid_username: 'Use 3–20 letters, numbers, dots, or underscores.', + invalid_password: 'Password must be 8–72 characters.', + invalid_credentials: 'The password for this iFruit email is incorrect.', 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.', diff --git a/frontend/src/types/crewlink.ts b/frontend/src/types/crewlink.ts index f27f0f5..1fa691a 100644 --- a/frontend/src/types/crewlink.ts +++ b/frontend/src/types/crewlink.ts @@ -88,6 +88,7 @@ export type CrewLinkLimits = { export type CrewLinkBootstrap = { activeGroup?: CrewLinkGroup | null + authenticated?: boolean groups: CrewLinkGroupSummary[] invitations: CrewLinkInvitation[] limits?: CrewLinkLimits diff --git a/frontend/src/views/apps/CrewLinkApp.vue b/frontend/src/views/apps/CrewLinkApp.vue index f058b7e..1c8e43f 100644 --- a/frontend/src/views/apps/CrewLinkApp.vue +++ b/frontend/src/views/apps/CrewLinkApp.vue @@ -166,6 +166,7 @@ const sheet = ref(null) const username = ref('') const authMode = ref<'login' | 'register'>('login') const authUsername = ref('') +const authPassword = ref('') const authProfilePhoto = ref(null) const authPending = ref(false) const authError = ref('') @@ -408,6 +409,7 @@ function showToast(message: string): void { function switchAuthMode(mode: 'login' | 'register'): void { authMode.value = mode authProfilePhoto.value = null + authPassword.value = '' authUsername.value = mode === 'register' ? (account.email.split('@')[0] ?? '') @@ -419,6 +421,8 @@ function switchAuthMode(mode: 'login' | 'register'): void { function authErrorText(code?: string): string { const known = [ + 'invalid_credentials', + 'invalid_password', 'invalid_profile_image', 'invalid_username', 'no_ifruit_account', @@ -438,56 +442,45 @@ async function submitAuthentication(): Promise { authError.value = authErrorText('no_ifruit_account') return } - if (!authUsernameValid.value) { + if (authPassword.value.length < 8 || authPassword.value.length > 72) { + authError.value = authErrorText('invalid_password') + return + } + if (authMode.value === 'register' && !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) + const response = + authMode.value === 'login' + ? await crew.login(authPassword.value) + : await crew.register( + submittedUsername, + authPassword.value, + authProfilePhoto.value?.id ?? 0, + ) + authPending.value = false + if (!response.success || !crew.profile) { + authError.value = authErrorText(response.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 = '' + authPassword.value = '' authProfilePhoto.value = null openSharedInvite() } +async function handleLoggedOut(): Promise { + await crew.logout() + activeTab.value = 'map' + authPassword.value = '' +} + function openAuthMedia(app: 'camera' | 'photos'): void { messageMedia.begin( 'crewlink:auth-avatar', @@ -657,16 +650,6 @@ function removeProfilePhoto(): void { profileAvatarRemoved.value = true } -async function createProfile(): Promise { - const response = await crew.createProfile(username.value.trim()) - if (!response.success) { - formError.value = errorText(response.error) - return - } - showToast(t('profileCreated')) - openSharedInvite() -} - async function createGroup(): Promise { const response = await crew.createGroup( groupName.value.trim(), @@ -1136,7 +1119,10 @@ onMounted(async () => { authProfilePhoto.value = authSelection.media[0] ?? authSelection.context?.selectedPhoto ?? null } - if (appAuth.isSignedIn('crewlink')) await crew.bootstrap() + if (appAuth.isSignedIn('crewlink')) { + await crew.bootstrap() + if (!crew.authenticated) appAuth.signOut('crewlink') + } username.value = crew.profile?.username ?? '' if (profileSelection && crew.profile) { username.value = profileSelection.context?.username ?? crew.profile.username @@ -1173,6 +1159,7 @@ onBeforeUnmount(() => { { :max-username-length="20" :min-username-length="3" :pending="authPending" + :password-label="t('password')" + :password-placeholder="t('passwordPlaceholder')" :register-label="t('register')" + require-password :title="t('authTitle')" :username-label="t('username')" :username-placeholder="t('usernamePlaceholder')" + variant="centered" @camera="openAuthMedia('camera')" @gallery="openAuthMedia('photos')" @submit="submitAuthentication" @@ -1218,57 +1209,11 @@ onBeforeUnmount(() => { - - - - - - {{ t('welcomeEyebrow') }} - {{ t('welcomeTitle') }} - {{ t('welcomeBody') }} - - - - - {{ formError }} - - - - {{ t('createProfile') }} - - - {{ phone.t('Common.signOut') }} - - {{ t('privacyNote') }} - - - - + @@ -1481,7 +1426,10 @@ onBeforeUnmount(() => { - + {{ t('activeCrew') }} @@ -1674,7 +1622,10 @@ onBeforeUnmount(() => { - + { @@ -2372,7 +2327,7 @@ onBeforeUnmount(() => { v-model:opened="logoutDialogOpen" app-id="crewlink" :app-name="t('name')" - @logged-out="activeTab = 'map'" + @logged-out="handleLoggedOut" /> { --cl-surface: rgba(255, 255, 255, 0.84); --cl-text: #102034; --cl-muted: #6e7c8d; + --cl-map-header-background: linear-gradient(90deg, #061823, #0f2837); --sky-safe-area-top: 46px; --sky-safe-area-bottom: 25px; position: relative; @@ -2428,22 +2384,13 @@ onBeforeUnmount(() => { } .crewlink-navbar { --sky-safe-area-top: 46px; + --sky-navbar-glass: var(--cl-bg); position: absolute; z-index: 20; top: 0; right: 0; left: 0; } -.crewlink-navbar::after { - position: absolute; - top: 100%; - right: 0; - left: 0; - height: 22px; - background: linear-gradient(var(--cl-bg), transparent); - content: ''; - pointer-events: none; -} .crewlink-content { position: absolute; inset: 0; @@ -2476,8 +2423,11 @@ onBeforeUnmount(() => { .crewlink-auth { --auth-accent: #20bde0; --panel: rgba(18, 39, 53, 0.92); - padding: 60px 18px 30px; - justify-content: flex-start; + box-sizing: border-box; + min-height: 100%; + padding: calc(var(--sky-safe-area-top) + 20px) 18px + calc(var(--sky-safe-area-bottom) + 20px); + justify-content: center; color: #f3f8fb; background: radial-gradient(circle at 82% 8%, rgba(39, 217, 237, 0.2), transparent 34%), @@ -2485,7 +2435,9 @@ onBeforeUnmount(() => { overflow-y: auto; } .crewlink-auth :deep(.app-profile-auth) { - margin: 0 auto; + width: min(100%, 340px); + flex: none; + margin: auto; } .crewlink-logo { width: 78px; @@ -2522,6 +2474,15 @@ onBeforeUnmount(() => { .crewlink-onboarding :deep(.button) { width: 100%; } +.crewlink-navbar--map { + --sky-text: #f3f8fb; + --sky-muted: #aab7c1; + background: var(--cl-map-header-background); +} +.crewlink-navbar--map :deep(.sky-navbar__blur), +.crewlink-navbar--map :deep(.sky-navbar__background) { + display: none; +} .crewlink-orbits { width: 170px; height: 170px; @@ -2703,17 +2664,27 @@ onBeforeUnmount(() => { padding-bottom: var(--sky-safe-area-bottom); } .crewlink-map-summary { + position: relative; + z-index: 1001; height: 64px; padding: 8px 13px; display: flex; align-items: center; justify-content: space-between; color: white; - background: linear-gradient( - 90deg, - rgba(6, 24, 35, 0.96), - rgba(15, 41, 56, 0.95) - ); + background: var(--cl-map-header-background); +} +.crewlink-map-summary::after { + position: absolute; + top: 100%; + right: 0; + left: 0; + height: 16px; + background: var(--cl-map-header-background); + -webkit-mask-image: linear-gradient(to bottom, #000, transparent); + mask-image: linear-gradient(to bottom, #000, transparent); + content: ''; + pointer-events: none; } .crewlink-map-summary > div:first-child { display: grid; @@ -3020,6 +2991,14 @@ onBeforeUnmount(() => { linear-gradient(145deg, #0b2433, #0b1825); box-shadow: 0 15px 35px rgba(6, 20, 31, 0.2); } +.crewlink-group-tab { + --sky-card-outer-left: 0px; + --sky-card-outer-right: 0px; + --sky-list-outer-left: 0px; + --sky-list-outer-right: 0px; + --sky-title-gutter-left: 0px; + --sky-title-gutter-right: 0px; +} .crewlink-group-hero__signal { width: 48px; height: 48px; @@ -3311,6 +3290,12 @@ onBeforeUnmount(() => { color: white; background: linear-gradient(145deg, var(--crew), #183a5a); } +.crewlink-profile-tab { + --sky-list-outer-left: 0px; + --sky-list-outer-right: 0px; + --sky-title-gutter-left: 0px; + --sky-title-gutter-right: 0px; +} .crewlink-profile-card > span { width: 54px; height: 54px; @@ -3413,6 +3398,16 @@ onBeforeUnmount(() => { background: var(--cl-bg); border-radius: 24px 24px 0 0; } +.crewlink-sheet__panel__content--manage { + --sky-card-outer-left: 0px; + --sky-card-outer-right: 0px; + --sky-list-outer-left: 0px; + --sky-list-outer-right: 0px; +} +.crewlink-sheet__panel__content--ping { + --sky-list-outer-left: 0px; + --sky-list-outer-right: 0px; +} .crewlink-sheet__panel__close { position: absolute; right: 14px; diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 707e004..0d3a4b2 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -85,9 +85,7 @@ function memoWaveform(phase = 0) { let authenticated = true let draft = null -let mockMailboxes = [ - { count: 0, id: 7, name: 'Projects', sort_order: 0 }, -] +let mockMailboxes = [{ count: 0, id: 7, name: 'Projects', sort_order: 0 }] let nextMockMailboxId = 8 const radioData = { badge: '231', @@ -218,6 +216,8 @@ let crewLinkProfile = { overheadVisible: false, username: 'Skyline', } +const crewLinkTestPassword = 'CrewLink123!' +let crewLinkAuthenticated = false let crewLinkGroups = [ { allowMemberPings: true, @@ -420,14 +420,18 @@ const crewLinkLimits = { } function crewLinkBootstrap(testScenario = '') { + if (!crewLinkAuthenticated) { + return { authenticated: false, groups: [], invitations: [], profile: null } + } if ( testScenario === 'crewlink-onboarding' || (testScenario === 'crewlink-register' && !crewLinkProfile) ) { - return { groups: [], invitations: [], profile: null } + return { authenticated: true, groups: [], invitations: [], profile: null } } if (testScenario === 'crewlink-empty') { return { + authenticated: true, activeGroup: null, groups: [], invitations: crewLinkInvitations, @@ -439,6 +443,7 @@ function crewLinkBootstrap(testScenario = '') { (group) => group.id === crewLinkProfile.activeGroupId, ) return { + authenticated: true, activeGroup: activeSummary ? { ...activeSummary, @@ -3425,8 +3430,7 @@ function counts() { return { drafts: draft ? 1 : 0, inbox: messages.filter( - (item) => - item.folder === 'inbox' && !item.mailbox_id && !item.trashed_at, + (item) => item.folder === 'inbox' && !item.mailbox_id && !item.trashed_at, ).length, sent: messages.filter( (item) => item.folder === 'sent' && !item.mailbox_id && !item.trashed_at, @@ -3446,8 +3450,7 @@ function mailboxViews() { return mockMailboxes.map((mailbox) => ({ ...mailbox, count: messages.filter( - (message) => - message.mailbox_id === mailbox.id && !message.trashed_at, + (message) => message.mailbox_id === mailbox.id && !message.trashed_at, ).length, })) } @@ -4127,15 +4130,13 @@ function companyWorkContext(testScenario = '') { app.post('/api/:endpoint', async (request, response, next) => { const endpoint = request.params.endpoint - console.log( - `[NUI] ${endpoint}`, - endpoint === 'memos:devCapture' - ? { - ...request.body, - audioDataUrl: `<${String(request.body.audioDataUrl ?? '').length} characters>`, - } - : request.body, - ) + const loggedBody = { ...request.body } + if (typeof loggedBody.password === 'string') + loggedBody.password = '' + if (endpoint === 'memos:devCapture') { + loggedBody.audioDataUrl = `<${String(request.body.audioDataUrl ?? '').length} characters>` + } + console.log(`[NUI] ${endpoint}`, loggedBody) if (endpoint === 'music:bootstrap') { response.json({ success: true, data: musicBootstrap() }) return @@ -5616,12 +5617,30 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: crewLinkBootstrap(testScenario) }) return } - if (endpoint === 'crewlink:create-profile') { + if (endpoint === 'crewlink:login') { + if (!crewLinkProfile || request.body.password !== crewLinkTestPassword) { + response.json({ success: false, error: 'invalid_credentials' }) + return + } + crewLinkAuthenticated = true + response.json({ success: true, data: crewLinkBootstrap(testScenario) }) + return + } + if (endpoint === 'crewlink:register') { const username = String(request.body.username ?? '').trim() if (!/^[A-Za-z0-9][A-Za-z0-9._]{1,18}[A-Za-z0-9]$/.test(username)) { response.json({ success: false, error: 'invalid_username' }) return } + const password = String(request.body.password ?? '') + if (password.length < 8 || password.length > 72) { + response.json({ success: false, error: 'invalid_password' }) + return + } + if (crewLinkProfile) { + response.json({ success: false, error: 'profile_exists' }) + return + } crewLinkProfile = { activeGroupId: null, avatarMediaId: Number(request.body.avatarMediaId) || null, @@ -5633,7 +5652,13 @@ app.post('/api/:endpoint', (request, response) => { overheadVisible: false, username, } - response.json({ success: true, data: crewLinkBootstrap() }) + crewLinkAuthenticated = true + response.json({ success: true, data: crewLinkBootstrap(testScenario) }) + return + } + if (endpoint === 'crewlink:logout') { + crewLinkAuthenticated = false + response.json({ success: true }) return } if (endpoint === 'crewlink:update-profile') { @@ -8585,6 +8610,7 @@ app.post('/api/:endpoint', (request, response) => { return } if (endpoint === 'development:bootstrap') { + crewLinkAuthenticated = false authenticated = testScenario !== 'setup-account-unlinked' linkedAccount = authenticated ? { @@ -10364,13 +10390,13 @@ app.post('/api/:endpoint', (request, response) => { ? messages.filter( (item) => item.mailbox_id === mailboxId && !item.trashed_at, ) - : messages.filter((item) => - folder === 'trash' - ? item.trashed_at - : item.folder === folder && - !item.mailbox_id && - !item.trashed_at, - ) + : messages.filter((item) => + folder === 'trash' + ? item.trashed_at + : item.folder === folder && + !item.mailbox_id && + !item.trashed_at, + ) const query = String(search).toLowerCase() if (query) { items = items.filter((item) => diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index 25e1d33..aaec0a8 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -17,6 +17,7 @@ const browserDataRequests = [ ['companies:work-context', {}], ['companies:work-queue', { limit: 20, offset: 0 }], ['contacts:list', {}], + ['crewlink:login', { password: 'CrewLink123!' }], ['crewlink:bootstrap', {}], ['crewlink:live', {}], ['crewlink:nearby', {}], @@ -733,12 +734,7 @@ async function verifyStatefulActions(baseUrl) { { name: 'Browser Test' }, true, ) - const mailboxes = await expectSuccess( - baseUrl, - 'mail:mailboxes', - {}, - true, - ) + const mailboxes = await expectSuccess(baseUrl, 'mail:mailboxes', {}, true) assert( mailboxes.mailboxes.some((item) => item.id === mailbox.id), 'mail:create-mailbox did not update the mock mailbox list', diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index b4179c7..bc6f75a 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -510,6 +510,8 @@ Config.MapMarkers = { Config.CrewLink = { UsernameMinLength = 3, UsernameMaxLength = 20, + PasswordMinLength = 8, + PasswordMaxLength = 72, GroupNameMinLength = 3, GroupNameMaxLength = 32, MaximumGroupsPerProfile = 5, @@ -651,6 +653,7 @@ if IsDuplicityVersion() then Config.Server = { -- Keep these values private and stable. Changing a pepper invalidates existing app passwords. PasscodePepper = "f626581802800478346266e66414d8e6f2c28050214a593c25901904c162bffe", + CrewLinkPasswordPepper = "2751e5729aee4955a529c1a87104b58fbe1d7280b18b66264236028caa06eb47", FlipTokPasswordPepper = "a85ea307680f1205a4fda03be8af18ecf7edf16ffc83450b90cac7e41a9719a7", PicstagramPasswordPepper = "653e41dd19aba5ef750a668ad1886273ba7d0e0420bd5c745748df80a6e22676", } diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index eb2763a..f97b921 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -378,10 +378,10 @@ Locales["en"] = { name = "CrewLink", connecting = "Connecting your crew...", privateNetwork = "Private location network", signInTitle = "Connect your Sky Cloud account", signInBody = "CrewLink uses your private Sky Cloud identity to keep groups and roles available across your phones.", openSettings = "Open Sky Cloud 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 = "Photos", camera = "Camera", backToLogin = "Back to CrewLink login", + authBody = "Your iFruit email is linked automatically. Enter your password to continue.", + login = "Log in", register = "Register", ifruitEmail = "iFruit address", password = "Password", passwordPlaceholder = "8-72 characters", gallery = "Photos", camera = "Camera", backToLogin = "Back to CrewLink login", authErrors = { - no_ifruit_account = "Sign in to your Sky Cloud account in Settings first.", invalid_username = "Use 3-20 letters, numbers, dots, or underscores.", + no_ifruit_account = "Sign in to your Sky Cloud account in Settings first.", invalid_username = "Use 3-20 letters, numbers, dots, or underscores.", invalid_password = "Password must be 8-72 characters.", invalid_credentials = "The password for this iFruit email is incorrect.", 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.", diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 96fd6fc..a0c7b04 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -186,7 +186,9 @@ local server_callbacks = { "map:create-marker", "map:delete-marker", "crewlink:bootstrap", - "crewlink:create-profile", + "crewlink:login", + "crewlink:register", + "crewlink:logout", "crewlink:update-profile", "crewlink:create-group", "crewlink:update-group", diff --git a/sky_phone/source/server/crewlink.lua b/sky_phone/source/server/crewlink.lua index 7019ffd..a765e58 100644 --- a/sky_phone/source/server/crewlink.lua +++ b/sky_phone/source/server/crewlink.lua @@ -1,4 +1,5 @@ Bridge.Database.AfterMigration("sky_phone", function() +local password_pepper = tostring(Config.Server.CrewLinkPasswordPepper or "") local role_levels = { guest = 1, member = 2, @@ -32,6 +33,14 @@ local live_sources_cache = { sources = {}, } +if password_pepper == "" then + Bridge.Debug( + "warn", + "[sky_phone] Config.Server.CrewLinkPasswordPepper is empty. CrewLink passwords still work, but their hashes lack the required server-side secret. Set a stable random value in config/config.lua before production; changing it later invalidates existing CrewLink passwords.", + { always = true } + ) +end + local function affected_rows(result) if type(result) == "number" then return result @@ -90,26 +99,31 @@ local function profile_dto(row) } end -local function require_profile(source) - local account, error_response = SkyPhone.RequireAccount(source) - if not account then +local function profile_for_session(source) + local session, error_response = SkyPhone.RequireSession(source) + if not session then return nil, error_response end local rows = Bridge.Database.Query([[ SELECT p.`id`, p.`account_id`, p.`username`, p.`avatar_media_id`, p.`active_group_id`, p.`map_visible`, p.`overhead_visible`, avatar.`url` AS `avatar_url` - FROM `sky_phone_crewlink_profiles` p + FROM `sky_phone_crewlink_sessions` crew_session + JOIN `sky_phone_crewlink_profiles` p ON p.`id` = crew_session.`profile_id` LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id` - WHERE p.`account_id` = ? + WHERE crew_session.`device_imei` = ? LIMIT 1 - ]], { account.id }) + ]], { session.imei }) if not rows[1] then - return nil, { success = false, error = "profile_required" } + return nil, { success = false, error = "not_authenticated" } end rows[1].account_id = tonumber(rows[1].account_id) return rows[1] end +local function require_profile(source) + return profile_for_session(source) +end + local function membership(profile_id, group_id) local rows = Bridge.Database.Query([[ SELECT m.`group_id`, m.`profile_id`, m.`role`, m.`joined_at`, @@ -359,6 +373,13 @@ local function valid_username(value) return username end +local function valid_password(value) + local length = type(value) == "string" and utf8.len(value) or nil + return length + and length >= Config.CrewLink.PasswordMinLength + and length <= Config.CrewLink.PasswordMaxLength +end + local function valid_group_name(value) local name = trim(value) return valid_text(name, Config.CrewLink.GroupNameMinLength, Config.CrewLink.GroupNameMaxLength) @@ -399,26 +420,23 @@ Bridge.Callbacks.Register("sky_phone:crewlink:bootstrap", function(source) ) then return { success = false, error = "rate_limited" } end - local account, error_response = SkyPhone.RequireAccount(source) - if not account then - return error_response + local profile, error_response = profile_for_session(source) + if not profile then + if error_response and error_response.error ~= "not_authenticated" then + return error_response + end + return { + success = true, + data = { authenticated = false, profile = nil, groups = {}, invitations = {} }, + } end - local rows = Bridge.Database.Query([[ - SELECT p.`id`, p.`account_id`, p.`username`, p.`avatar_media_id`, p.`active_group_id`, - p.`map_visible`, p.`overhead_visible`, avatar.`url` AS `avatar_url` - FROM `sky_phone_crewlink_profiles` p - LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id` - WHERE p.`account_id` = ? LIMIT 1 - ]], { account.id }) - if not rows[1] then - return { success = true, data = { profile = nil, groups = {}, invitations = {} } } - end - rows[1].account_id = tonumber(rows[1].account_id) - return { success = true, data = bootstrap(rows[1]) } + local data = bootstrap(profile) + data.authenticated = true + return { success = true, data = data } end) -Bridge.Callbacks.Register("sky_phone:crewlink:create-profile", function(source, data) - if not allow(source, "profile") then +Bridge.Callbacks.Register("sky_phone:crewlink:register", function(source, data) + if not SkyPhone.AllowOperation(source, "crewlink:register", 5, 60) then return { success = false, error = "rate_limited" } end local account, error_response = SkyPhone.RequireAccount(source) @@ -430,6 +448,9 @@ Bridge.Callbacks.Register("sky_phone:crewlink:create-profile", function(source, if not username then return { success = false, error = "invalid_username" } end + if not valid_password(data.password) then + return { success = false, error = "invalid_password" } + end local avatar_media_id = tonumber(data.avatarMediaId) or 0 if avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id) then return { success = false, error = "invalid_profile_image" } @@ -437,15 +458,107 @@ Bridge.Callbacks.Register("sky_phone:crewlink:create-profile", function(source, if avatar_media_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, avatar_media_id, "photo") then return { success = false, error = "invalid_profile_image" } end - local result = Bridge.Database.Query([[ - INSERT IGNORE INTO `sky_phone_crewlink_profiles` (`id`, `account_id`, `username`, `avatar_media_id`) - VALUES (?, ?, ?, NULLIF(?, 0)) - ]], { new_id(), account.id, username, avatar_media_id }) - if affected_rows(result) ~= 1 then + local existing = Bridge.Database.Query([[ + SELECT p.`id`, c.`profile_id` AS `credential_profile_id` + FROM `sky_phone_crewlink_profiles` p + LEFT JOIN `sky_phone_crewlink_credentials` c ON c.`profile_id` = p.`id` + WHERE p.`account_id` = ? LIMIT 1 + ]], { account.id })[1] + if existing and existing.credential_profile_id then + return { success = false, error = "profile_exists" } + end + local duplicate = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_crewlink_profiles` + WHERE `username` = ? AND `account_id` <> ? LIMIT 1 + ]], { username, account.id }) + if duplicate[1] then return { success = false, error = "username_taken" } end + local entropy = Bridge.Database.Query( + "SELECT UUID() AS `id`, REPLACE(UUID(), '-', '') AS `salt`", + {} + )[1] + if not entropy or type(entropy.id) ~= "string" or type(entropy.salt) ~= "string" then + error("[sky_phone] Database did not generate CrewLink registration entropy.") + end + local profile_id = existing and existing.id or entropy.id + local queries = {} + if existing then + queries[#queries + 1] = { + query = [[UPDATE `sky_phone_crewlink_profiles` + SET `username` = ?, `avatar_media_id` = NULLIF(?, 0) WHERE `id` = ?]], + params = { username, avatar_media_id, profile_id }, + } + else + queries[#queries + 1] = { + query = [[INSERT INTO `sky_phone_crewlink_profiles` + (`id`, `account_id`, `username`, `avatar_media_id`) VALUES (?, ?, ?, NULLIF(?, 0))]], + params = { profile_id, account.id, username, avatar_media_id }, + } + end + queries[#queries + 1] = { + query = [[INSERT INTO `sky_phone_crewlink_credentials` + (`profile_id`, `password_hash`, `password_salt`) + VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?)]], + params = { profile_id, password_pepper, entropy.salt, data.password, entropy.salt }, + } + queries[#queries + 1] = { + query = [[INSERT INTO `sky_phone_crewlink_sessions` (`device_imei`, `profile_id`) + VALUES (?, ?) ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`), + `updated_at` = CURRENT_TIMESTAMP]], + params = { account.imei, profile_id }, + } + if not Bridge.Database.Transaction(queries) then + return { success = false, error = "request_failed" } + end local profile = require_profile(source) - return { success = true, data = bootstrap(profile) } + local response = bootstrap(profile) + response.authenticated = true + return { success = true, data = response } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:login", function(source, data) + if not SkyPhone.AllowOperation(source, "crewlink:login", 10, 60) then + return { success = false, error = "rate_limited" } + end + local account, error_response = SkyPhone.RequireAccount(source) + if not account then + return error_response + end + if type(data) ~= "table" or not valid_password(data.password) then + return { success = false, error = "invalid_credentials" } + end + local profiles = Bridge.Database.Query([[ + SELECT p.`id` FROM `sky_phone_crewlink_profiles` p + JOIN `sky_phone_crewlink_credentials` c ON c.`profile_id` = p.`id` + WHERE p.`account_id` = ? + AND c.`password_hash` = UNHEX(SHA2(CONCAT(?, c.`password_salt`, ?), 256)) + LIMIT 1 + ]], { account.id, password_pepper, data.password }) + if not profiles[1] then + return { success = false, error = "invalid_credentials" } + end + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crewlink_sessions` (`device_imei`, `profile_id`) + VALUES (?, ?) ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`), + `updated_at` = CURRENT_TIMESTAMP + ]], { account.imei, profiles[1].id }) + local profile = require_profile(source) + local response = bootstrap(profile) + response.authenticated = true + return { success = true, data = response } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:logout", function(source) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + Bridge.Database.Query( + "DELETE FROM `sky_phone_crewlink_sessions` WHERE `device_imei` = ?", + { session.imei } + ) + return { success = true } end) Bridge.Callbacks.Register("sky_phone:crewlink:update-profile", function(source, data) diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 3607bbb..69802c7 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -2391,6 +2391,39 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_crewlink_credentials", + columns = { + { name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "password_hash", type = "BINARY(32) NOT NULL" }, + { name = "password_salt", type = "CHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "profile_id", + foreignKeys = { + { column = "profile_id", references = "`sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_crewlink_sessions", + columns = { + { name = "device_imei", type = "CHAR(15) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "device_imei", + indexes = { + { name = "idx_sky_phone_crewlink_sessions_profile", columns = "(`profile_id`, `updated_at`)" }, + }, + foreignKeys = { + { column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" }, + { column = "profile_id", references = "`sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, { name = "sky_phone_crewlink_groups", columns = { diff --git a/sky_phone/source/server/testdata.lua b/sky_phone/source/server/testdata.lua index 990d9e2..bc466c3 100644 --- a/sky_phone/source/server/testdata.lua +++ b/sky_phone/source/server/testdata.lua @@ -741,6 +741,19 @@ local function seed_private_and_services(context) ]], { crew_bot_seed, bot_id }) local crew_user = ensure_string_profile("sky_phone_crewlink_profiles", account_id) local crew_bot = ensure_string_profile("sky_phone_crewlink_profiles", bot_id) + local crew_password_salt = crew_user_seed:gsub("-", "") + local crew_bot_password_salt = crew_bot_seed:gsub("-", "") + local crew_password_pepper = tostring(Config.Server.CrewLinkPasswordPepper or "") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crewlink_credentials` (`profile_id`, `password_hash`, `password_salt`) + VALUES (?, UNHEX(SHA2(CONCAT(?, ?, 'CrewLink123!'), 256)), ?), + (?, UNHEX(SHA2(CONCAT(?, ?, 'CrewLink123!'), 256)), ?) + ON DUPLICATE KEY UPDATE `password_hash` = VALUES(`password_hash`), + `password_salt` = VALUES(`password_salt`) + ]], { + crew_user, crew_password_pepper, crew_password_salt, crew_password_salt, + crew_bot, crew_password_pepper, crew_bot_password_salt, crew_bot_password_salt, + }) Bridge.Database.Query([[ INSERT INTO `sky_phone_crewlink_groups` (`id`, `name`, `colour`, `owner_profile_id`, `invite_code`, `allow_member_pings`, `overhead_allowed`) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 9cfc4cf..9c9bf76 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -1060,6 +1060,27 @@ CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_profiles` ( FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_credentials` ( + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `password_hash` BINARY(32) NOT NULL, + `password_salt` CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`profile_id`), + FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_sessions` ( + `device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`device_imei`), + KEY `idx_sky_phone_crewlink_sessions_profile` (`profile_id`,`updated_at`), + FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE, + FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_groups` ( `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `name` VARCHAR(32) NOT NULL,
{{ t('welcomeBody') }}
- {{ formError }} -