ENH - refine CrewLink layouts and authentication

This commit is contained in:
smx.pusha
2026-08-17 09:46:33 +02:00
parent 7d3a3b7b38
commit 05444c84ec
15 changed files with 743 additions and 188 deletions
@@ -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)),
)
})
</script>
<template>
<section class="app-profile-auth">
<section
class="app-profile-auth"
:class="{ 'app-profile-auth--centered': variant === 'centered' }"
>
<header class="app-profile-auth__hero">
<span class="app-profile-auth__mark"><UserRound :size="23" /></span>
<div>
@@ -75,7 +94,39 @@ const canSubmit = computed(() => {
</header>
<k-glass class="app-profile-auth__card">
<k-segmented raised class="app-profile-auth__mode">
<div
v-if="variant === 'centered'"
class="app-profile-auth__mode app-profile-auth__mode--centered"
role="tablist"
:aria-label="`${loginLabel} / ${registerLabel}`"
>
<button
type="button"
class="app-profile-auth__mode-choice"
:class="{
'app-profile-auth__mode-choice--active': mode === 'login',
}"
role="tab"
:aria-selected="mode === 'login'"
@click="emit('update:mode', 'login')"
>
{{ loginLabel }}
</button>
<button
type="button"
class="app-profile-auth__mode-choice"
:class="{
'app-profile-auth__mode-choice--active': mode === 'register',
}"
role="tab"
:aria-selected="mode === 'register'"
@click="emit('update:mode', 'register')"
>
{{ registerLabel }}
</button>
</div>
<k-segmented v-else raised class="app-profile-auth__mode">
<k-segmented-button
class="app-profile-auth__mode-button app-profile-auth__mode-button--login"
:class="{
@@ -123,7 +174,64 @@ const canSubmit = computed(() => {
<LockKeyhole :size="15" />
</div>
<k-list inset strong class="app-profile-auth__fields">
<label
v-if="
variant === 'centered' && (mode === 'register' || !requirePassword)
"
class="app-profile-auth__username-field"
for="app-profile-auth-username"
>
<span><UserRound :size="17" /></span>
<div>
<small>{{ usernameLabel }}</small>
<input
id="app-profile-auth-username"
:value="username"
:maxlength="maxUsernameLength"
:placeholder="usernamePlaceholder"
autocomplete="username"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
@input="
emit('update:username', ($event.target as HTMLInputElement).value)
"
@keydown.enter="emit('submit')"
/>
</div>
</label>
<label
v-if="variant === 'centered' && requirePassword"
class="app-profile-auth__password-field"
for="app-profile-auth-password"
>
<span><LockKeyhole :size="17" /></span>
<div>
<small>{{ passwordLabel }}</small>
<input
id="app-profile-auth-password"
:value="password"
maxlength="72"
:placeholder="passwordPlaceholder"
:autocomplete="
mode === 'login' ? 'current-password' : 'new-password'
"
type="password"
@input="
emit('update:password', ($event.target as HTMLInputElement).value)
"
@keydown.enter="emit('submit')"
/>
</div>
</label>
<k-list
v-else-if="variant !== 'centered'"
inset
strong
class="app-profile-auth__fields"
>
<k-list-input
input-id="app-profile-auth-username"
:label="usernameLabel"
@@ -380,6 +488,102 @@ const canSubmit = computed(() => {
.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;
}
</style>
+23
View File
@@ -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 = {
+17 -2
View File
@@ -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<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:login', { password })
},
register(
username: string,
password: string,
avatarMediaId = 0,
): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:create-profile', {
return this.request('crewlink:register', {
avatarMediaId,
password,
username,
})
},
logout(): Promise<NuiResponse> {
this.authenticated = false
this.profile = null
this.groups = []
this.activeGroup = null
this.invitations = []
return nuiCall('crewlink:logout')
},
updateProfile(
username: string,
mapVisible: boolean,
+5 -1
View File
@@ -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: '872 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 320 letters, numbers, dots, or underscores.',
invalid_password: 'Password must be 872 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.',
+1
View File
@@ -88,6 +88,7 @@ export type CrewLinkLimits = {
export type CrewLinkBootstrap = {
activeGroup?: CrewLinkGroup | null
authenticated?: boolean
groups: CrewLinkGroupSummary[]
invitations: CrewLinkInvitation[]
limits?: CrewLinkLimits
+109 -114
View File
@@ -166,6 +166,7 @@ const sheet = ref<CrewLinkSheet>(null)
const username = ref('')
const authMode = ref<'login' | 'register'>('login')
const authUsername = ref('')
const authPassword = ref('')
const authProfilePhoto = ref<PhoneMedia | null>(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<void> {
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<void> {
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<void> {
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<void> {
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(() => {
<div class="crewlink-onboarding crewlink-auth">
<AppProfileAuth
:mode="authMode"
v-model:password="authPassword"
v-model:username="authUsername"
:avatar-url="authProfilePhoto?.url ?? null"
:body="t('authBody')"
@@ -1186,10 +1173,14 @@ 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(() => {
</div>
</template>
<template v-else-if="!crew.profile">
<div class="crewlink-onboarding crewlink-onboarding--profile">
<div class="crewlink-orbits" aria-hidden="true">
<i></i><i></i><i></i><span><Radio /></span>
</div>
<small>{{ t('welcomeEyebrow') }}</small>
<h1>{{ t('welcomeTitle') }}</h1>
<p>{{ t('welcomeBody') }}</p>
<sky-list inset strong class="crewlink-form-list">
<sky-field
input-id="crewlink-username"
:label="t('username')"
:placeholder="t('usernamePlaceholder')"
:value="username"
maxlength="20"
outline
@input="updateValue('username', $event)"
@keydown.enter="handleEnterAction($event, createProfile)"
/>
</sky-list>
<p v-if="formError" class="crewlink-error" role="alert">
{{ formError }}
</p>
<sky-button
large
rounded
:disabled="crew.isLoading"
@click="createProfile"
>
<sky-spinner v-if="crew.isLoading" />
<template v-else>{{ t('createProfile') }}</template>
</sky-button>
<sky-button
large
rounded
outline
class="crewlink-logout-button"
@click="logoutDialogOpen = true"
>
<LogOut />{{ phone.t('Common.signOut') }}
</sky-button>
<small class="crewlink-privacy"
><ShieldCheck />{{ t('privacyNote') }}</small
>
</div>
</template>
<template v-else>
<template v-else-if="crew.profile">
<sky-navbar
v-if="activeGroup"
class="crewlink-navbar"
:class="{ 'crewlink-navbar--map': activeTab === 'map' }"
:subtitle="headerSubtitle"
:title="headerTitle"
/>
@@ -1481,7 +1426,10 @@ onBeforeUnmount(() => {
</div>
</section>
<section v-show="activeTab === 'group'" class="crewlink-scroll-tab">
<section
v-show="activeTab === 'group'"
class="crewlink-scroll-tab crewlink-group-tab"
>
<div class="crewlink-group-hero" :style="activeCrewStyle">
<div class="crewlink-group-hero__signal"><Radio /></div>
<small>{{ t('activeCrew') }}</small>
@@ -1674,7 +1622,10 @@ onBeforeUnmount(() => {
</div>
</section>
<section v-show="activeTab === 'profile'" class="crewlink-scroll-tab">
<section
v-show="activeTab === 'profile'"
class="crewlink-scroll-tab crewlink-profile-tab"
>
<div
class="crewlink-profile-card"
:style="{ '--crew': activeColour }"
@@ -1891,6 +1842,10 @@ onBeforeUnmount(() => {
<section
v-if="sheet"
class="crewlink-sheet__panel__content"
:class="{
'crewlink-sheet__panel__content--manage': sheet === 'edit-group',
'crewlink-sheet__panel__content--ping': sheet === 'ping',
}"
role="dialog"
aria-modal="true"
>
@@ -2372,7 +2327,7 @@ onBeforeUnmount(() => {
v-model:opened="logoutDialogOpen"
app-id="crewlink"
:app-name="t('name')"
@logged-out="activeTab = 'map'"
@logged-out="handleLoggedOut"
/>
<sky-dialog
@@ -2409,6 +2364,7 @@ onBeforeUnmount(() => {
--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;
+52 -26
View File
@@ -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 = '<redacted>'
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) =>
+2 -6
View File
@@ -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',
+3
View File
@@ -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",
}
+3 -3
View File
@@ -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.",
+3 -1
View File
@@ -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",
+143 -30
View File
@@ -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)
+33
View File
@@ -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 = {
+13
View File
@@ -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`)
+21
View File
@@ -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,