initial commit

This commit is contained in:
Madison Grubb
2026-02-10 23:32:26 -05:00
commit b7046dc0e6
133 changed files with 26080 additions and 0 deletions

179
app/pages/account.vue Normal file
View File

@@ -0,0 +1,179 @@
<template>
<div class="p-6">
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
Account
</h2>
<!-- Profile -->
<section class="mb-8">
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
Profile
</h3>
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
<template v-if="user">
<dl class="space-y-2 text-sm">
<div>
<dt class="text-kestrel-muted">
Identifier
</dt>
<dd class="mt-0.5 font-medium text-kestrel-text">
{{ user.identifier }}
</dd>
</div>
<div>
<dt class="text-kestrel-muted">
Role
</dt>
<dd class="mt-0.5 font-medium text-kestrel-text">
{{ user.role }}
</dd>
</div>
<div>
<dt class="text-kestrel-muted">
Sign-in method
</dt>
<dd class="mt-0.5 font-medium text-kestrel-text">
{{ user.auth_provider === 'oidc' ? 'OIDC' : 'Local' }}
</dd>
</div>
</dl>
<p class="mt-3 text-xs text-kestrel-muted">
Admins can manage all users on the Members page.
</p>
</template>
<p
v-else
class="text-sm text-kestrel-muted"
>
Sign in to see your profile.
</p>
</div>
</section>
<!-- Change password (local only) -->
<section
v-if="user?.auth_provider === 'local'"
class="mb-8"
>
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
Change password
</h3>
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
<p
v-if="passwordSuccess"
class="mb-3 text-sm text-green-400"
>
Password updated.
</p>
<p
v-if="passwordError"
class="mb-3 text-sm text-red-400"
>
{{ passwordError }}
</p>
<form
class="space-y-3"
@submit.prevent="onChangePassword"
>
<div>
<label
for="account-current-password"
class="mb-1 block text-xs text-kestrel-muted"
>
Current password
</label>
<input
id="account-current-password"
v-model="currentPassword"
type="password"
autocomplete="current-password"
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
>
</div>
<div>
<label
for="account-new-password"
class="mb-1 block text-xs text-kestrel-muted"
>
New password
</label>
<input
id="account-new-password"
v-model="newPassword"
type="password"
autocomplete="new-password"
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
>
</div>
<div>
<label
for="account-confirm-password"
class="mb-1 block text-xs text-kestrel-muted"
>
Confirm new password
</label>
<input
id="account-confirm-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
>
</div>
<button
type="submit"
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90 disabled:opacity-50"
:disabled="passwordLoading"
>
{{ passwordLoading ? 'Updating…' : 'Update password' }}
</button>
</form>
</div>
</section>
</div>
</template>
<script setup>
const { user } = useUser()
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const passwordLoading = ref(false)
const passwordSuccess = ref(false)
const passwordError = ref('')
async function onChangePassword() {
passwordError.value = ''
passwordSuccess.value = false
if (newPassword.value !== confirmPassword.value) {
passwordError.value = 'New password and confirmation do not match.'
return
}
if (newPassword.value.length < 1) {
passwordError.value = 'New password cannot be empty.'
return
}
passwordLoading.value = true
try {
await $fetch('/api/me/password', {
method: 'PUT',
body: {
currentPassword: currentPassword.value,
newPassword: newPassword.value,
},
credentials: 'include',
})
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
passwordSuccess.value = true
}
catch (e) {
passwordError.value = e.data?.message ?? e.message ?? 'Failed to update password.'
}
finally {
passwordLoading.value = false
}
}
</script>

85
app/pages/cameras.vue Normal file
View File

@@ -0,0 +1,85 @@
<template>
<div class="p-6">
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
Cameras
</h2>
<p class="mb-4 text-sm text-kestrel-muted">
Devices and live sessions. Select one to view the stream.
</p>
<div
v-if="!cameras?.length"
class="rounded border border-kestrel-border bg-kestrel-bg px-4 py-8 text-center text-sm text-kestrel-muted"
>
No cameras. Add devices or use
<NuxtLink
to="/share-live"
class="text-kestrel-accent underline"
>
Share live
</NuxtLink>
on your phone to stream; it will appear here and on the map.
</div>
<div
v-else
class="flex flex-col gap-4 md:flex-row md:flex-wrap"
>
<div class="flex-1 md:min-w-[320px]">
<ul class="space-y-2">
<li
v-for="cam in cameras"
:key="cam.id"
class="rounded border transition-colors"
:class="
selectedCamera?.id === cam.id
? 'border-kestrel-accent bg-kestrel-accent-dim'
: 'border-kestrel-border bg-kestrel-surface hover:border-kestrel-accent/50'
"
>
<button
type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left"
@click="selectedCamera = cam"
>
<span class="font-medium text-kestrel-text">{{ cam.name ?? cam.label }}</span>
<span
v-if="cam.hasStream"
class="rounded bg-green-500/20 px-2 py-0.5 text-xs text-green-400"
>
Live
</span>
<span
v-else-if="cam.device_type"
class="rounded bg-kestrel-surface px-2 py-0.5 text-xs text-kestrel-muted capitalize"
>
{{ cam.device_type }}
</span>
</button>
</li>
</ul>
</div>
<div class="flex-1 md:min-w-[360px]">
<CameraViewer
v-if="selectedCamera"
:camera="selectedCamera"
inline
@close="selectedCamera = null"
/>
<div
v-else
class="flex aspect-video items-center justify-center rounded border border-kestrel-border bg-kestrel-bg text-sm text-kestrel-muted"
>
Select a camera to view
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({ layout: 'default' })
const { cameras } = useCameras()
const selectedCamera = ref(null)
</script>

35
app/pages/index.vue Normal file
View File

@@ -0,0 +1,35 @@
<template>
<div class="flex h-[calc(100vh-5rem)] w-full flex-col md:flex-row">
<div class="relative h-2/3 w-full md:h-full md:flex-1">
<ClientOnly>
<KestrelMap
:feeds="[]"
:devices="devices ?? []"
:pois="pois ?? []"
:live-sessions="liveSessions ?? []"
:can-edit-pois="canEditPois"
@select="selectedCamera = $event"
@select-live="onSelectLive($event)"
@refresh-pois="refreshPois"
/>
</ClientOnly>
</div>
<CameraViewer
v-if="selectedCamera"
:camera="selectedCamera"
@close="selectedCamera = null"
/>
</div>
</template>
<script setup>
const { devices, liveSessions } = useCameras()
const { data: pois, refresh: refreshPois } = usePois()
const { canEditPois } = useUser()
const selectedCamera = ref(null)
function onSelectLive(session) {
const latest = (liveSessions.value || []).find(s => s.id === session?.id)
selectedCamera.value = latest ?? session
}
</script>

114
app/pages/login.vue Normal file
View File

@@ -0,0 +1,114 @@
<template>
<div class="flex min-h-[60vh] items-center justify-center p-6">
<div class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
<h2 class="mb-4 text-lg font-semibold text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
Sign in
</h2>
<p
v-if="error"
class="mb-3 text-xs text-red-400"
>
{{ error }}
</p>
<a
v-if="authConfig?.oidc?.enabled"
:href="oidcAuthorizeUrl"
class="mb-4 flex w-full items-center justify-center rounded bg-kestrel-accent px-3 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90"
>
{{ authConfig.oidc.label }}
</a>
<p
v-if="showDivider"
class="mb-3 text-center text-xs text-kestrel-muted"
>
or
</p>
<form
@submit.prevent="onSubmit"
>
<div class="mb-3">
<label
for="login-identifier"
class="mb-1 block text-xs text-kestrel-muted"
>Email or username</label>
<input
id="login-identifier"
v-model="identifier"
type="text"
autocomplete="username"
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
required
>
</div>
<div class="mb-4">
<label
for="login-password"
class="mb-1 block text-xs text-kestrel-muted"
>Password</label>
<input
id="login-password"
v-model="password"
type="password"
autocomplete="current-password"
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
required
>
</div>
<button
type="submit"
class="w-full rounded bg-kestrel-accent px-3 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90"
:disabled="loading"
>
{{ loading ? 'Signing in…' : 'Sign in' }}
</button>
</form>
</div>
</div>
</template>
<script setup>
const route = useRoute()
const redirect = computed(() => route.query.redirect || '/')
const { data: authConfig } = useAsyncData(
'auth-config',
() => $fetch('/api/auth/config').catch(() => ({ oidc: { enabled: false, label: '' } })),
{ default: () => null },
)
const showDivider = computed(() => !!authConfig.value?.oidc?.enabled)
const oidcAuthorizeUrl = computed(() => {
const base = '/api/auth/oidc/authorize'
const q = redirect.value && redirect.value !== '/' ? `?redirect=${encodeURIComponent(redirect.value)}` : ''
return base + q
})
const identifier = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const { refresh } = useUser()
async function onSubmit() {
error.value = ''
loading.value = true
try {
await $fetch('/api/auth/login', {
method: 'POST',
body: { identifier: identifier.value, password: password.value },
})
await refresh()
const target = redirect.value || '/'
// Full page redirect so the session cookie is sent on the next request (fixes mobile Safari staying on login)
if (import.meta.client && typeof window !== 'undefined') {
window.location.href = target
return
}
await navigateTo(target)
}
catch (e) {
error.value = e?.data?.message ?? e?.message ?? 'Sign in failed'
}
finally {
loading.value = false
}
}
</script>

565
app/pages/members.vue Normal file
View File

@@ -0,0 +1,565 @@
<template>
<div class="p-6">
<h2 class="mb-2 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
Members
</h2>
<p
v-if="!user"
class="text-sm text-kestrel-muted"
>
Sign in to view members.
</p>
<p
v-else-if="!canViewMembers"
class="text-sm text-kestrel-muted"
>
You don't have access to the members list.
</p>
<template v-else>
<p
v-if="isAdmin"
class="mb-3 text-xs text-kestrel-muted"
>
Only admins can change roles and manage local users. OIDC users are managed via your identity provider.
</p>
<div
v-if="isAdmin"
class="mb-3 flex justify-start"
>
<button
type="button"
class="rounded border border-kestrel-accent bg-kestrel-accent/10 px-3 py-1.5 text-sm font-medium text-kestrel-accent hover:bg-kestrel-accent-dim"
@click="openAddUserModal"
>
Add user
</button>
</div>
<div class="overflow-x-auto rounded border border-kestrel-border">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-kestrel-border bg-kestrel-surface-hover">
<th class="px-4 py-2 font-medium text-kestrel-text">
Identifier
</th>
<th class="px-4 py-2 font-medium text-kestrel-text">
Auth
</th>
<th class="px-4 py-2 font-medium text-kestrel-text">
Role
</th>
<th
v-if="isAdmin"
class="px-4 py-2 font-medium text-kestrel-text"
>
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="u in users"
:key="u.id"
class="border-b border-kestrel-border"
>
<td class="px-4 py-2 text-kestrel-text">
{{ u.identifier }}
</td>
<td class="px-4 py-2">
<span
class="rounded px-1.5 py-0.5 text-xs text-kestrel-muted"
:class="u.auth_provider === 'oidc' ? 'bg-kestrel-surface' : ''"
>
{{ u.auth_provider === 'oidc' ? 'OIDC' : 'Local' }}
</span>
</td>
<td class="px-4 py-2">
<div
v-if="isAdmin"
:ref="el => setDropdownWrapRef(u.id, el)"
class="relative inline-block"
>
<button
type="button"
class="flex min-w-[6rem] items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-left text-sm text-kestrel-text shadow-sm transition-colors hover:border-kestrel-accent/50 hover:bg-kestrel-surface"
:aria-expanded="openRoleDropdownId === u.id"
:aria-haspopup="true"
aria-label="Change role"
@click.stop="toggleRoleDropdown(u.id)"
>
<span>{{ roleByUserId[u.id] ?? u.role }}</span>
<span
class="text-kestrel-muted transition-transform"
:class="openRoleDropdownId === u.id && 'rotate-180'"
>
</span>
</button>
</div>
<span
v-else
class="text-kestrel-muted"
>{{ u.role }}</span>
</td>
<td
v-if="isAdmin"
class="px-4 py-2"
>
<div class="flex flex-wrap items-center gap-2">
<button
v-if="roleByUserId[u.id] !== u.role"
type="button"
class="rounded border border-kestrel-accent px-2 py-1 text-xs text-kestrel-accent hover:bg-kestrel-accent-dim"
@click="saveRole(u.id)"
>
Save role
</button>
<template v-if="u.auth_provider !== 'oidc'">
<button
type="button"
class="rounded border border-kestrel-border px-2 py-1 text-xs text-kestrel-text hover:bg-kestrel-surface"
@click="openEditUser(u)"
>
Edit
</button>
<button
v-if="u.id !== user?.id"
type="button"
class="rounded border border-red-500/60 px-2 py-1 text-xs text-red-400 hover:bg-red-500/10"
@click="openDeleteConfirm(u)"
>
Remove
</button>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="openRoleDropdownId && dropdownPlacement"
ref="dropdownMenuRef"
role="menu"
class="fixed z-[100] min-w-[6rem] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_4px_12px_-2px_rgba(34,201,201,0.15)]"
:style="{
top: `${dropdownPlacement.top}px`,
left: `${dropdownPlacement.left}px`,
minWidth: `${dropdownPlacement.minWidth}px`,
}"
>
<button
v-for="role in roleOptions"
:key="role"
type="button"
role="menuitem"
class="block w-full px-3 py-1.5 text-left text-sm transition-colors"
:class="roleByUserId[openRoleDropdownId] === role
? 'bg-kestrel-accent-dim text-kestrel-accent'
: 'text-kestrel-text hover:bg-kestrel-border hover:text-kestrel-text'"
@click.stop="selectRole(openRoleDropdownId, role)"
>
{{ role }}
</button>
</div>
</Transition>
</Teleport>
<!-- Add user modal -->
<Teleport to="body">
<div
v-if="addUserModalOpen"
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="add-user-title"
@click.self="closeAddUserModal"
>
<div
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
@click.stop
>
<h3
id="add-user-title"
class="mb-3 text-sm font-medium text-kestrel-text"
>
Add user
</h3>
<form @submit.prevent="submitAddUser">
<div class="mb-3 flex flex-col gap-1">
<label
for="add-identifier"
class="text-xs text-kestrel-muted"
>Username</label>
<input
id="add-identifier"
v-model="newUser.identifier"
type="text"
required
autocomplete="username"
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
placeholder="username"
>
</div>
<div class="mb-3 flex flex-col gap-1">
<label
for="add-password"
class="text-xs text-kestrel-muted"
>Password</label>
<input
id="add-password"
v-model="newUser.password"
type="password"
required
autocomplete="new-password"
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
placeholder="••••••••"
>
</div>
<div class="mb-4 flex flex-col gap-1">
<label
for="add-role"
class="text-xs text-kestrel-muted"
>Role</label>
<select
id="add-role"
v-model="newUser.role"
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
>
<option value="member">
member
</option>
<option value="leader">
leader
</option>
<option value="admin">
admin
</option>
</select>
</div>
<p
v-if="createError"
class="mb-2 text-xs text-red-400"
>
{{ createError }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
@click="closeAddUserModal"
>
Cancel
</button>
<button
type="submit"
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
>
Add user
</button>
</div>
</form>
</div>
</div>
</Teleport>
<!-- Delete user confirmation modal -->
<Teleport to="body">
<div
v-if="deleteConfirmUser"
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="delete-user-title"
@click.self="deleteConfirmUser = null"
>
<div
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
@click.stop
>
<h3
id="delete-user-title"
class="mb-2 text-sm font-medium text-kestrel-text"
>
Delete user?
</h3>
<p class="mb-4 text-sm text-kestrel-muted">
Are you sure you want to delete <strong class="text-kestrel-text">{{ deleteConfirmUser?.identifier }}</strong>? They will not be able to sign in again.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
@click="deleteConfirmUser = null"
>
Cancel
</button>
<button
type="button"
class="rounded border border-red-500/60 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/20"
@click="confirmDeleteUser"
>
Delete
</button>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="editUserModal"
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
@click.self="editUserModal = null"
>
<div
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
role="dialog"
aria-modal="true"
aria-labelledby="edit-user-title"
>
<h3
id="edit-user-title"
class="mb-3 text-sm font-medium text-kestrel-text"
>
Edit local user
</h3>
<form @submit.prevent="submitEditUser">
<div class="mb-3 flex flex-col gap-1">
<label
for="edit-identifier"
class="text-xs text-kestrel-muted"
>Identifier</label>
<input
id="edit-identifier"
v-model="editForm.identifier"
type="text"
required
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
>
</div>
<div class="mb-4 flex flex-col gap-1">
<label
for="edit-password"
class="text-xs text-kestrel-muted"
>New password (leave blank to keep)</label>
<input
id="edit-password"
v-model="editForm.password"
type="password"
autocomplete="new-password"
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
placeholder="••••••••"
>
<p class="mt-0.5 text-xs text-kestrel-muted">
If you change your password, use the new one next time you sign in.
</p>
</div>
<p
v-if="editError"
class="mb-2 text-xs text-red-400"
>
{{ editError }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
@click="editUserModal = null"
>
Cancel
</button>
<button
type="submit"
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
>
Save
</button>
</div>
</form>
</div>
</div>
</Teleport>
</template>
</div>
</template>
<script setup>
const { user, isAdmin, refresh: refreshUser } = useUser()
const canViewMembers = computed(() => user.value?.role === 'admin' || user.value?.role === 'leader')
const { data: usersData, refresh: refreshUsers } = useAsyncData(
'users',
() => $fetch('/api/users').catch(() => []),
{ default: () => [] },
)
const users = computed(() => (Array.isArray(usersData.value) ? usersData.value : []))
const roleOptions = ['admin', 'leader', 'member']
const pendingRoleUpdates = ref({})
const roleByUserId = computed(() => {
const base = Object.fromEntries(users.value.map(u => [u.id, u.role]))
return { ...base, ...pendingRoleUpdates.value }
})
const openRoleDropdownId = ref(null)
const dropdownWrapRefs = ref({})
const dropdownPlacement = ref(null)
const dropdownMenuRef = ref(null)
const addUserModalOpen = ref(false)
const newUser = ref({ identifier: '', password: '', role: 'member' })
const createError = ref('')
const editUserModal = ref(null)
const editForm = ref({ identifier: '', password: '' })
const editError = ref('')
const deleteConfirmUser = ref(null)
function setDropdownWrapRef(userId, el) {
if (el) dropdownWrapRefs.value[userId] = el
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
else delete dropdownWrapRefs.value[userId]
}
watch(user, (u) => {
if (u?.role === 'admin' || u?.role === 'leader') refreshUsers()
}, { immediate: true })
function toggleRoleDropdown(userId) {
if (openRoleDropdownId.value === userId) {
openRoleDropdownId.value = null
dropdownPlacement.value = null
return
}
openRoleDropdownId.value = userId
nextTick(() => {
const wrap = dropdownWrapRefs.value[userId]
if (wrap) {
const rect = wrap.getBoundingClientRect()
dropdownPlacement.value = {
top: rect.bottom + 4,
left: rect.left,
minWidth: Math.max(rect.width, 96),
}
}
else {
dropdownPlacement.value = { top: 0, left: 0, minWidth: 96 }
}
})
}
function selectRole(userId, role) {
pendingRoleUpdates.value = { ...pendingRoleUpdates.value, [userId]: role }
openRoleDropdownId.value = null
dropdownPlacement.value = null
}
function onDocumentClick(e) {
const openId = openRoleDropdownId.value
if (openId == null) return
const wrap = dropdownWrapRefs.value[openId]
const menu = dropdownMenuRef.value
const inTrigger = wrap && wrap.contains(e.target)
const inMenu = menu && menu.contains(e.target)
if (!inTrigger && !inMenu) {
openRoleDropdownId.value = null
dropdownPlacement.value = null
}
}
onMounted(() => {
document.addEventListener('click', onDocumentClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
})
async function saveRole(id) {
const role = roleByUserId.value[id]
if (!role) return
try {
await $fetch(`/api/users/${id}`, { method: 'PATCH', body: { role } })
await refreshUsers()
const { [id]: _, ...rest } = pendingRoleUpdates.value
pendingRoleUpdates.value = rest
}
catch {
// could set error state
}
}
function openAddUserModal() {
addUserModalOpen.value = true
newUser.value = { identifier: '', password: '', role: 'member' }
createError.value = ''
}
function closeAddUserModal() {
addUserModalOpen.value = false
createError.value = ''
}
async function submitAddUser() {
createError.value = ''
try {
await $fetch('/api/users', {
method: 'POST',
body: {
identifier: newUser.value.identifier.trim(),
password: newUser.value.password,
role: newUser.value.role,
},
})
closeAddUserModal()
await refreshUsers()
}
catch (e) {
createError.value = e.data?.message || e.message || 'Failed to create user'
}
}
function openEditUser(u) {
editUserModal.value = u
editForm.value = { identifier: u.identifier, password: '' }
editError.value = ''
}
async function submitEditUser() {
if (!editUserModal.value) return
editError.value = ''
const id = editUserModal.value.id
const body = { identifier: editForm.value.identifier.trim() }
if (editForm.value.password) body.password = editForm.value.password
try {
await $fetch(`/api/users/${id}`, { method: 'PATCH', body })
editUserModal.value = null
await refreshUsers()
// If you edited yourself, refresh current user so the header/nav shows the new identifier
await refreshUser()
}
catch (e) {
editError.value = e.data?.message || e.message || 'Failed to update user'
}
}
function openDeleteConfirm(u) {
deleteConfirmUser.value = u
}
async function confirmDeleteUser() {
const u = deleteConfirmUser.value
if (!u) return
try {
await $fetch(`/api/users/${u.id}`, { method: 'DELETE' })
deleteConfirmUser.value = null
await refreshUsers()
}
catch (e) {
alert(e.data?.message || e.message || 'Failed to remove user')
}
}
</script>

170
app/pages/poi.vue Normal file
View File

@@ -0,0 +1,170 @@
<template>
<div class="p-6">
<h2 class="mb-2 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
POI placement
</h2>
<p
v-if="!canEditPois"
class="mb-4 text-sm text-kestrel-muted"
>
View-only. Sign in as admin or leader to add or edit POIs.
</p>
<template v-else>
<form
class="mb-6 flex flex-wrap items-end gap-3 rounded border border-kestrel-border bg-kestrel-surface p-4"
@submit.prevent="onAdd"
>
<div>
<label
for="poi-lat"
class="mb-1 block text-xs text-kestrel-muted"
>Lat</label>
<input
id="poi-lat"
v-model.number="form.lat"
type="number"
step="any"
required
class="w-28 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
>
</div>
<div>
<label
for="poi-lng"
class="mb-1 block text-xs text-kestrel-muted"
>Lng</label>
<input
id="poi-lng"
v-model.number="form.lng"
type="number"
step="any"
required
class="w-28 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
>
</div>
<div>
<label
for="poi-label"
class="mb-1 block text-xs text-kestrel-muted"
>Label</label>
<input
id="poi-label"
v-model="form.label"
type="text"
class="w-40 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
>
</div>
<div>
<label
for="poi-icon"
class="mb-1 block text-xs text-kestrel-muted"
>Icon</label>
<select
id="poi-icon"
v-model="form.iconType"
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
>
<option value="pin">
pin
</option>
<option value="flag">
flag
</option>
<option value="waypoint">
waypoint
</option>
</select>
</div>
<button
type="submit"
class="rounded bg-kestrel-accent px-3 py-1.5 text-sm font-medium text-kestrel-bg hover:opacity-90"
>
Add POI
</button>
</form>
</template>
<div class="overflow-x-auto rounded border border-kestrel-border">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-kestrel-border bg-kestrel-surface-hover">
<th class="px-4 py-2 font-medium text-kestrel-text">
Label
</th>
<th class="px-4 py-2 font-medium text-kestrel-text">
Lat
</th>
<th class="px-4 py-2 font-medium text-kestrel-text">
Lng
</th>
<th class="px-4 py-2 font-medium text-kestrel-text">
Icon
</th>
<th
v-if="canEditPois"
class="px-4 py-2 font-medium text-kestrel-text"
>
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="p in poisList"
:key="p.id"
class="border-b border-kestrel-border"
>
<td class="px-4 py-2 text-kestrel-text">
{{ p.label || '—' }}
</td>
<td class="px-4 py-2 text-kestrel-muted">
{{ p.lat }}
</td>
<td class="px-4 py-2 text-kestrel-muted">
{{ p.lng }}
</td>
<td class="px-4 py-2 text-kestrel-muted">
{{ p.icon_type }}
</td>
<td
v-if="canEditPois"
class="px-4 py-2"
>
<button
type="button"
class="text-xs text-red-400 hover:underline"
@click="remove(p.id)"
>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
const { data: poisData, refresh } = usePois()
const { canEditPois } = useUser()
const poisList = computed(() => poisData.value ?? [])
const form = ref({ lat: 37.77, lng: -122.42, label: '', iconType: 'pin' })
async function onAdd() {
const { lat, lng, label, iconType } = form.value
try {
await $fetch('/api/pois', { method: 'POST', body: { lat, lng, label, iconType } })
await refresh()
}
catch { /* ignore */ }
}
async function remove(id) {
try {
await $fetch(`/api/pois/${id}`, { method: 'DELETE' })
await refresh()
}
catch { /* ignore */ }
}
</script>

114
app/pages/settings.vue Normal file
View File

@@ -0,0 +1,114 @@
<template>
<div class="p-6">
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
Settings
</h2>
<!-- Map & offline -->
<section class="mb-8">
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
Map & offline
</h3>
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
<p class="mb-3 text-sm text-kestrel-text">
Clear saved map tiles to free storage. The map will load tiles from the network again when you use it.
</p>
<p
v-if="tilesStored !== null"
class="mb-2 text-xs text-kestrel-muted"
>
{{ tilesStored > 0 ? `${tilesStored} tiles stored.` : 'No tiles stored.' }}
</p>
<p
v-if="tilesMessage"
class="mb-2 text-sm"
:class="tilesMessageSuccess ? 'text-green-400' : 'text-red-400'"
>
{{ tilesMessage }}
</p>
<button
type="button"
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border disabled:opacity-50"
:disabled="tilesLoading"
@click="onClearTiles"
>
{{ tilesLoading ? 'Clearing…' : 'Clear saved map tiles' }}
</button>
</div>
</section>
<!-- About -->
<section>
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
About
</h3>
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
<p class="font-medium text-kestrel-text">
KestrelOS
</p>
<p
v-if="version"
class="mt-1 text-sm text-kestrel-muted"
>
Version {{ version }}
</p>
<p class="mt-2 text-xs text-kestrel-muted">
Tactical Operations Center for OSINT feeds.
</p>
</div>
</section>
</div>
</template>
<script setup>
const config = useRuntimeConfig()
const version = config.public?.version ?? null
const tilesStored = ref(null)
const tilesMessage = ref('')
const tilesMessageSuccess = ref(false)
const tilesLoading = ref(false)
async function loadTilesStored() {
if (typeof window === 'undefined') return
try {
const offline = await import('leaflet.offline')
if (offline.getStorageLength) {
const n = await offline.getStorageLength()
tilesStored.value = n
}
}
catch {
tilesStored.value = null
}
}
async function onClearTiles() {
tilesMessage.value = ''
tilesLoading.value = true
try {
const offline = await import('leaflet.offline')
if (offline.truncate) {
await offline.truncate()
tilesStored.value = 0
tilesMessage.value = 'Saved map tiles cleared.'
tilesMessageSuccess.value = true
}
else {
tilesMessage.value = 'Could not clear tiles.'
tilesMessageSuccess.value = false
}
}
catch (e) {
tilesMessage.value = e?.message ?? 'Failed to clear tiles.'
tilesMessageSuccess.value = false
}
finally {
tilesLoading.value = false
}
}
onMounted(() => {
loadTilesStored()
})
</script>

406
app/pages/share-live.vue Normal file
View File

@@ -0,0 +1,406 @@
<template>
<div class="flex min-h-[80vh] flex-col items-center justify-center p-6">
<div class="w-full max-w-md rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_24px_-6px_rgba(34,201,201,0.2)]">
<h2 class="mb-2 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
Share live (camera + location)
</h2>
<p class="mb-4 text-sm text-kestrel-muted">
Use this page in Safari on your iPhone to stream your camera and location to the map. You'll appear as a live POI.
</p>
<div
v-if="!isSecureContext"
class="mb-4 rounded border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm text-amber-200"
>
<strong>HTTPS required.</strong> From your phone, camera and location only work over a secure connection. Open this app using an HTTPS URL (e.g. a tunnel like ngrok, or a server with an SSL certificate).
</div>
<!-- Status -->
<div class="mb-4 rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text">
<p
v-if="status"
class="font-medium"
>
{{ status }}
</p>
<p
v-if="webrtcState === 'connecting'"
class="mt-1 text-kestrel-muted"
>
WebRTC: connecting…
</p>
<template v-if="webrtcState === 'failed'">
<p class="mt-1 font-medium text-red-400">
WebRTC: failed
</p>
<p
v-if="webrtcFailureReason?.wrongHost"
class="mt-1 text-amber-400"
>
Wrong host: server sees <strong>{{ webrtcFailureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ webrtcFailureReason.wrongHost.clientHostname }}</strong>. Use the same URL on phone and server, or set MEDIASOUP_ANNOUNCED_IP.
</p>
<ul class="mt-2 list-inside list-disc space-y-0.5 text-kestrel-muted">
<li><strong>Firewall:</strong> Open UDP/TCP ports 4000049999 on the server.</li>
<li><strong>Wrong host:</strong> Server must see the same address you use (see above or open /api/live/debug-request-host).</li>
<li><strong>Restrictive NAT / cellular:</strong> A TURN server may be required (future enhancement).</li>
</ul>
</template>
<p
v-if="error"
class="mt-1 text-red-400"
>
{{ error }}
</p>
</div>
<!-- Local preview -->
<div
v-if="stream && videoRef"
class="relative mb-4 aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black"
>
<video
ref="videoRef"
autoplay
playsinline
muted
class="h-full w-full object-cover"
/>
<div
v-if="sharing"
class="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-1 text-xs text-green-400"
>
● Live — you appear on the map
</div>
</div>
<!-- Controls -->
<div class="flex flex-col gap-2">
<button
v-if="!sharing"
type="button"
class="w-full rounded bg-kestrel-accent px-4 py-3 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90 disabled:opacity-50"
:disabled="starting"
@click="startSharing"
>
{{ starting ? 'Starting' : 'Start sharing' }}
</button>
<button
v-else
type="button"
class="w-full rounded border border-red-400/60 bg-red-400/10 px-4 py-3 text-sm font-medium text-red-400 transition-opacity hover:bg-red-400/20"
@click="stopSharing"
>
Stop sharing
</button>
<NuxtLink
to="/"
class="block text-center text-sm text-kestrel-muted underline hover:text-kestrel-accent"
>
Back to map
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup>
import { createMediasoupDevice, createSendTransport } from '~/composables/useWebRTC.js'
import { getWebRTCFailureReason } from '~/composables/useWebRTCFailureReason.js'
import { initLogger, logError, logWarn } from '~/utils/logger.js'
import { useUser } from '~/composables/useUser.js'
definePageMeta({ layout: 'default' })
const { user } = useUser()
const videoRef = ref(null)
const stream = ref(null)
const sessionId = ref(null)
const status = ref('')
const error = ref('')
const sharing = ref(false)
const starting = ref(false)
const isSecureContext = typeof window !== 'undefined' && window.isSecureContext
const webrtcState = ref('') // '', 'connecting', 'connected', 'failed'
const webrtcFailureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
let locationWatchId = null
let locationIntervalId = null
let device = null
let sendTransport = null
let producer = null
async function runFailureReasonCheck() {
webrtcFailureReason.value = await getWebRTCFailureReason()
}
function setStatus(msg) {
status.value = msg
error.value = ''
}
function setError(msg) {
error.value = msg
}
async function startSharing() {
starting.value = true
setStatus('Requesting camera and location…')
setError('')
try {
// 1. Start live session on server
const session = await $fetch('/api/live/start', {
method: 'POST',
body: {},
})
sessionId.value = session.id
// Initialize logger with session and user context
initLogger(session.id, user.value?.id)
setStatus('Session started. Requesting camera…')
// 2. Get camera if available (requires HTTPS on mobile Safari)
const hasMediaDevices = typeof navigator !== 'undefined' && navigator.mediaDevices != null
if (!hasMediaDevices) {
setError('Media devices not available. HTTPS required.')
cleanup()
return
}
let mediaStream = null
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
})
stream.value = mediaStream
if (videoRef.value) {
videoRef.value.srcObject = mediaStream
}
setStatus('Camera on. Setting up WebRTC…')
}
catch {
setError('Camera denied or unavailable. Allow camera access in browser settings.')
cleanup()
return
}
// 3. Initialize Mediasoup device and create WebRTC transport
try {
webrtcState.value = 'connecting'
webrtcFailureReason.value = null
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${sessionId.value}`, {
credentials: 'include',
})
device = await createMediasoupDevice(rtpCapabilities)
sendTransport = await createSendTransport(device, sessionId.value, {
onConnectSuccess: () => { webrtcState.value = 'connected' },
onConnectFailure: () => {
webrtcState.value = 'failed'
runFailureReasonCheck()
},
})
// 4. Produce video track
const videoTrack = mediaStream.getVideoTracks()[0]
if (!videoTrack) {
throw new Error('No video track available')
}
producer = await sendTransport.produce({ track: videoTrack })
// Monitor producer events
producer.on('transportclose', () => {
logWarn('share-live: Producer transport closed', {
producerId: producer.id,
producerPaused: producer.paused,
producerClosed: producer.closed,
})
})
producer.on('trackended', () => {
logWarn('share-live: Producer track ended', {
producerId: producer.id,
producerPaused: producer.paused,
producerClosed: producer.closed,
})
})
// Monitor transport state (mediasoup-client does not pass a parameter; read from transport.connectionState)
sendTransport.on('connectionstatechange', () => {
const state = sendTransport.connectionState
if (state === 'connected') webrtcState.value = 'connected'
else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn('share-live: Send transport connection state changed', {
state,
transportId: sendTransport.id,
producerId: producer.id,
})
if (state === 'failed') {
webrtcState.value = 'failed'
runFailureReasonCheck()
}
}
})
// Monitor track state
if (producer.track) {
producer.track.addEventListener('ended', () => {
logWarn('share-live: Producer track ended', {
producerId: producer.id,
trackId: producer.track.id,
trackReadyState: producer.track.readyState,
trackEnabled: producer.track.enabled,
trackMuted: producer.track.muted,
})
})
producer.track.addEventListener('mute', () => {
logWarn('share-live: Producer track muted', {
producerId: producer.id,
trackId: producer.track.id,
trackEnabled: producer.track.enabled,
trackMuted: producer.track.muted,
})
})
producer.track.addEventListener('unmute', () => {})
}
webrtcState.value = 'connected'
setStatus('WebRTC connected. Requesting location…')
}
catch (webrtcErr) {
logError('share-live: WebRTC setup error', { err: webrtcErr.message || String(webrtcErr), stack: webrtcErr.stack })
webrtcState.value = 'failed'
runFailureReasonCheck()
setError('Failed to set up video stream: ' + (webrtcErr.message || String(webrtcErr)))
cleanup()
return
}
// 5. Get location (continuous) — also requires HTTPS on mobile Safari
if (!navigator.geolocation) {
setError('Geolocation not supported in this browser.')
cleanup()
return
}
try {
await new Promise((resolve, reject) => {
locationWatchId = navigator.geolocation.watchPosition(
(pos) => {
resolve(pos)
},
(err) => {
reject(err)
},
{ enableHighAccuracy: true, maximumAge: 0, timeout: 10000 },
)
})
}
catch (locErr) {
const msg = locErr?.code === 1 || (locErr?.message && locErr.message.toLowerCase().includes('permission'))
? 'Camera and location require a secure connection (HTTPS) when using this page from your phone. Open this app via an HTTPS URL (e.g. use a tunnel or a server with SSL).'
: (locErr?.message || 'Location was denied or unavailable.')
setError(msg)
cleanup()
return
}
setStatus('Location enabled. Streaming live…')
sharing.value = true
starting.value = false
// 6. Send location updates periodically (video is handled by WebRTC)
let locationUpdate404Logged = false
const sendLocationUpdate = async () => {
if (!sessionId.value || !sharing.value) return
const id = sessionId.value
const pos = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
maximumAge: 2000,
timeout: 5000,
})
}).catch(() => null)
const lat = pos?.coords?.latitude
const lng = pos?.coords?.longitude
if (Number.isFinite(lat) && Number.isFinite(lng)) {
try {
await $fetch(`/api/live/${id}`, {
method: 'PATCH',
body: {
lat,
lng,
},
credentials: 'include',
})
}
catch (e) {
if (e?.statusCode === 404) {
if (locationIntervalId != null) {
clearInterval(locationIntervalId)
locationIntervalId = null
}
sharing.value = false
if (!locationUpdate404Logged) {
locationUpdate404Logged = true
logWarn('share-live: Session ended (404), stopping location updates', { sessionId: id })
}
}
else {
logWarn('share-live: Live location update failed', { err: e.message || String(e) })
}
}
}
}
await sendLocationUpdate()
locationIntervalId = setInterval(sendLocationUpdate, 2000)
}
catch (e) {
starting.value = false
if (e?.message) setError(e.message)
else if (e?.name === 'NotAllowedError') setError('Camera or location was denied. Allow in Safari settings.')
else if (e?.name === 'NotFoundError') setError('No camera found.')
else setError('Failed to start: ' + String(e))
cleanup()
}
}
function cleanup() {
if (locationWatchId != null && navigator.geolocation?.clearWatch) {
navigator.geolocation.clearWatch(locationWatchId)
}
locationWatchId = null
if (locationIntervalId != null) {
clearInterval(locationIntervalId)
}
locationIntervalId = null
if (producer) {
producer.close()
producer = null
}
if (sendTransport) {
sendTransport.close()
sendTransport = null
}
device = null
if (stream.value) {
stream.value.getTracks().forEach(t => t.stop())
stream.value = null
}
if (sessionId.value) {
$fetch(`/api/live/${sessionId.value}`, { method: 'DELETE' }).catch(() => {})
sessionId.value = null
}
sharing.value = false
webrtcState.value = ''
webrtcFailureReason.value = null
}
async function stopSharing() {
setStatus('Stopping…')
cleanup()
setStatus('')
setError('')
}
onBeforeUnmount(() => {
cleanup()
})
</script>