mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
ENH - refine CrewLink layouts and authentication
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -88,6 +88,7 @@ export type CrewLinkLimits = {
|
||||
|
||||
export type CrewLinkBootstrap = {
|
||||
activeGroup?: CrewLinkGroup | null
|
||||
authenticated?: boolean
|
||||
groups: CrewLinkGroupSummary[]
|
||||
invitations: CrewLinkInvitation[]
|
||||
limits?: CrewLinkLimits
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user