Files
PilotVault/Web App/web/src/components/Settings.vue
T
tajniak81andClaude Opus 4.8 3903428964 Let OpenWeather calls-per-minute limit cascade to orgs and users
Drop the descriptor Default of 60 so the global panel field can be left
blank. A non-blank global value sits at the top of the cascade and locks
every lower layer, so the fixed default made the per-minute limit
un-overridable by organizations and users. Blank now means "inherit";
the runtime still falls back to 60 when no layer sets a value, so the
usage gauge is unchanged. Update the descriptor help and the Settings
row copy to explain the inherit behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:02:31 +02:00

2832 lines
141 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed, reactive, provide, onMounted, onBeforeUnmount, watch } from 'vue'
import Icon from './Icon.vue'
import Toggle from './settings/Toggle.vue'
import Segmented from './settings/Segmented.vue'
import Row from './settings/Row.vue'
import { themeMode, setThemeMode } from '../theme.js'
import { prefs, formatDateTime, importPrefs, applyFontSize, applyReduceMotion } from '../prefs.js'
import { europeanCountries, regionOptions, countryGroups } from '../countries.js'
import {
getUsers, createUser, updateUser, deleteUser,
getOrgs, createOrg, updateOrg, deleteOrg,
getOpenSky, saveOpenSky, testOpenSky,
getFileTransfer, saveFileTransfer, testFileTransfer,
getLocalStorage, saveLocalStorage, testLocalStorage,
getWebDav, saveWebDav, testWebDav,
getOpenWeather, saveOpenWeather, testOpenWeather,
} from '../api.js'
const props = defineProps({
email: { type: String, default: '' },
role: { type: String, default: 'user' },
organization: { type: String, default: '' },
organizationName: { type: String, default: '' },
})
const emit = defineEmits(['logout'])
const isSuperadmin = computed(() => props.role === 'superadmin')
const isManager = computed(() => props.role === 'admin' || props.role === 'superadmin')
// Label + look for a role badge (handles the superadmin tier).
function roleLabel(r) {
return r === 'superadmin' ? 'Superadmin' : r === 'admin' ? 'Admin' : 'User'
}
function roleIcon(r) {
return r === 'superadmin' || r === 'admin' ? 'shield' : 'user'
}
function roleBadge(r) {
return r === 'superadmin' || r === 'admin' ? badgeClass.accent : badgeClass.neutral
}
const badgeClass = {
success: 'bg-success-soft text-success-fg',
warning: 'bg-amber-soft text-amber-fg',
danger: 'bg-danger-soft text-danger-fg',
accent: 'bg-accent-soft text-accent-soft-fg',
neutral: 'bg-surface-2 text-ink-secondary',
}
/* ---------- section model (ordered by frequency of use) ---------- */
const SECTIONS = computed(() => {
const list = [
{ id: 'account', label: 'Account', icon: 'user', kw: 'name username email password verification login credentials role' },
{ id: 'appearance', label: 'Appearance', icon: 'sliders', kw: 'theme light dark system language region font size accessibility date time format motion' },
{ id: 'integrations', label: 'Integrations', icon: 'radio', kw: 'opensky flights adsb aircraft plugin oauth credentials bounding box plan connection ftp sftp ftps file transfer server host upload download local storage folder drive isolated private read only webdav nextcloud owncloud dav url https openweather weather forecast temperature api key units calls per minute usage limit quota' },
{ id: 'profile', label: 'Profile', icon: 'image', kw: 'avatar photo display name bio public' },
{ id: 'security', label: 'Privacy & Security', icon: 'shield', kw: 'two factor authentication 2fa sessions devices logout security privacy' },
]
if (isManager.value) {
list.push({ id: 'team', label: 'User management', icon: 'users', kw: 'users team members add remove create delete role admin permissions rights organization' })
}
if (isSuperadmin.value) {
list.push({ id: 'organizations', label: 'Organizations', icon: 'grid', kw: 'organization org tenant company create rename delete members' })
}
list.push({ id: 'advanced', label: 'Advanced', icon: 'alertTriangle', kw: 'export import data delete account danger zone', danger: true })
return list
})
const section = ref('account')
const q = ref('')
provide('settingsSearch', q)
const searching = computed(() => q.value.trim().length > 0)
const ql = computed(() => q.value.trim().toLowerCase())
// Which sections have any match (drives section headers in search mode).
function sectionMatches(s) {
if (!ql.value) return true
return (s.label + ' ' + s.kw).toLowerCase().includes(ql.value) || rowsMatch(s.id)
}
// Rough per-section row match — mirrors Row's own filter so headers hide when empty.
const ROW_KW = {
account: ['full name', 'username', 'email address verification verify', 'password change current new'],
appearance: ['theme light dark system', 'language', 'region', 'font size accessibility', 'reduce motion', 'date format', 'time format clock'],
integrations: ['opensky live flights', 'enable plugin', 'oauth client id secret', 'plan credits', 'bounding box', 'test connection', 'file transfer ftp sftp ftps', 'server host port username password', 'private key passphrase', 'base path directory', 'local storage folder drive', 'private isolated folder', 'read only access mode', 'webdav nextcloud owncloud dav', 'server url username password tls', 'base path directory folder', 'openweather weather forecast', 'api key units metric imperial', 'default latitude longitude language', 'calls per minute limit usage quota', 'api call usage today rate limit'],
profile: ['profile photo avatar', 'display name', 'bio about', 'show email public'],
security: ['two factor authentication', 'active sessions devices', 'sign out'],
team: ['add user create account', 'members list role admin remove delete', 'organization org assign'],
organizations: ['add organization create', 'rename organization', 'delete organization members'],
advanced: ['export data download', 'import data upload', 'delete account permanent danger'],
}
function rowsMatch(id) {
if (!ql.value) return true
return (ROW_KW[id] || []).some((k) => k.includes(ql.value))
}
const visibleSections = computed(() =>
searching.value ? SECTIONS.value.filter(sectionMatches) : SECTIONS.value.filter((s) => s.id === section.value),
)
/* ---------- appearance ---------- */
const themeSel = computed({ get: () => themeMode.value, set: (v) => setThemeMode(v) })
const THEME_OPTS = [
{ value: 'light', label: 'Light', icon: 'sun' },
{ value: 'dark', label: 'Dark', icon: 'moon' },
{ value: 'system', label: 'System', icon: 'monitor' },
]
const FONT_OPTS = [
{ value: 'sm', label: 'Small' },
{ value: 'md', label: 'Default' },
{ value: 'lg', label: 'Large' },
]
const TIME_OPTS = [
{ value: '12', label: '12-hour' },
{ value: '24', label: '24-hour' },
]
const LANGS = [
['en', 'English'], ['es', 'Español'], ['de', 'Deutsch'], ['fr', 'Français'], ['pl', 'Polski'], ['ja', '日本語'],
]
// All countries of the world (also the final fallback for the auto bounding box).
const REGIONS = regionOptions()
// Display name of the currently selected Region (for the auto-bbox helper text).
const regionName = computed(() => (REGIONS.find(([v]) => v === prefs.region) || [null, prefs.region])[1])
const DATE_FMTS = [
['MDY', 'MM/DD/YYYY'], ['DMY', 'DD/MM/YYYY'], ['YMD', 'YYYY/MM/DD'], ['ISO', 'YYYY-MM-DD'],
]
// Live clock for the format preview.
const now = ref(Date.now())
let clock = null
const preview = computed(() => formatDateTime(now.value))
/* ---------- integrations: OpenSky ---------- */
// Resolved cascade view from the server (secrets/inherited creds already masked).
const os = reactive({
loaded: false,
available: false,
orgEnabled: true, // org master switch (admins toggle it; gates the org's users)
allowAnonymous: true,
enabled: false,
canEditOrg: false, // admin: may also edit organization-wide settings
isSuperadmin: false, // manages the global layer in the API panel (read-only here)
scopes: {}, // { user: { editableLayer, fields }, org?: { editableLayer, fields } }
})
// Which settings an admin is editing: 'user' (personal) or 'org' (organization-wide).
const osScope = ref('user')
const osForm = reactive({ clientId: '', clientSecret: '', plan: '', bbox: '' })
const osMsg = ref('')
const osSaving = ref(false)
const osTesting = ref(false)
const osHealth = ref(null) // { status, detail, latencyMs, credits }
const osHealthTs = ref(null) // epoch ms of the last probe (for "checked … ago")
const OS_HEALTH_KEY = 'pv.opensky.health' // persist last probe across reloads
// Structured credit accounting from the last probe (null until one runs).
const osCredits = computed(() => (osHealth.value && osHealth.value.credits) || null)
// Fraction of the daily allowance still available (null when remaining is unknown).
const osCreditPct = computed(() => {
const c = osCredits.value
if (!c || !c.daily || c.remaining == null) return null
return Math.max(0, Math.min(100, Math.round((c.remaining / c.daily) * 100)))
})
// Bar colour tracks how much headroom is left.
const osCreditBarClass = computed(() => {
const p = osCreditPct.value
if (p == null) return 'bg-accent'
if (p <= 10) return 'bg-danger'
if (p <= 30) return 'bg-amber'
return 'bg-success'
})
function osFmt(n) {
return typeof n === 'number' ? n.toLocaleString() : n
}
function osHealthAgo() {
if (!osHealthTs.value) return ''
const s = Math.max(0, Math.round((Date.now() - osHealthTs.value) / 1000))
if (s < 60) return 'just now'
const m = Math.round(s / 60)
if (m < 60) return `${m} min ago`
const h = Math.round(m / 60)
if (h < 24) return `${h} h ago`
return `${Math.round(h / 24)} d ago`
}
function persistOsHealth() {
try {
if (osHealth.value) {
localStorage.setItem(OS_HEALTH_KEY, JSON.stringify({ health: osHealth.value, ts: osHealthTs.value }))
}
} catch { /* storage unavailable — non-fatal */ }
}
function restoreOsHealth() {
try {
const raw = localStorage.getItem(OS_HEALTH_KEY)
if (!raw) return
const d = JSON.parse(raw)
if (d && d.health) {
osHealth.value = d.health
osHealthTs.value = d.ts || null
}
} catch { /* ignore corrupt cache */ }
}
const OS_PLAN_OPTS = [
{ value: '', label: 'Not set' },
{ value: 'anonymous', label: 'Anonymous' },
{ value: 'standard', label: 'Standard' },
{ value: 'contributor', label: 'Contributor' },
]
// Scope switcher (admins only): personal vs organization-wide settings.
const OS_SCOPE_OPTS = [
{ value: 'user', label: 'My settings', icon: 'user' },
{ value: 'org', label: 'Organization', icon: 'users' },
]
// Predefined bounding boxes (lamin,lomin,lamax,lomax). The picker offers these
// plus "Automatic" (the location cascade) and "Custom…" (manual coordinates).
// European countries come from the shared dataset in countries.js.
const OS_BBOX_GROUPS = [
{ label: 'World', options: [
{ value: '-90,-180,90,180', label: 'World' },
] },
{ label: 'Continents', options: [
{ value: '34,-25,72,45', label: 'Europe' },
{ value: '-35,-18,38,52', label: 'Africa' },
{ value: '5,25,82,180', label: 'Asia' },
{ value: '7,-168,72,-52', label: 'North America' },
{ value: '-56,-82,13,-34', label: 'South America' },
{ value: '-48,110,-10,180', label: 'Oceania' },
] },
{ label: 'European countries', options: europeanCountries() },
{ label: 'Other countries', options: [
{ value: '24,-125,49.5,-66.5', label: 'United States' },
{ value: '41.7,-141,83.1,-52.6', label: 'Canada' },
{ value: '-43.6,113.3,-10.7,153.6', label: 'Australia' },
{ value: '24,122.9,45.5,145.8', label: 'Japan' },
] },
]
const OS_BBOX_FLAT = OS_BBOX_GROUPS.flatMap((g) => g.options)
// Normalise a bbox string to "n1,n2,n3,n4" so preset matching ignores spacing
// and trailing-zero differences; returns '' when it isn't four valid numbers.
function osNormBbox(s) {
const parts = String(s || '').split(',').map((x) => x.trim())
if (parts.length !== 4) return ''
const nums = parts.map(Number)
if (nums.some((n) => Number.isNaN(n))) return ''
return nums.join(',')
}
// Friendly label for a bbox value when it matches a preset (else '').
function osBboxLabel(v) {
const norm = osNormBbox(v)
const m = norm && OS_BBOX_FLAT.find((o) => osNormBbox(o.value) === norm)
return m ? m.label : ''
}
// Manual-entry toggle: sticky once the user picks "Custom…", and implied when
// the current value doesn't match any preset.
const osBboxCustom = ref(false)
// The picker's value. "__auto__" is the location cascade (a personal client
// preference, prefs.autoBbox — not part of the server config, so it's only
// offered in the personal scope). "__custom__" reveals the coordinate field.
const osBboxPreset = computed({
get() {
if (!osEditingOrg.value && prefs.autoBbox) return '__auto__'
if (osBboxCustom.value) return '__custom__'
const norm = osNormBbox(osForm.bbox)
const match = norm && OS_BBOX_FLAT.find((o) => osNormBbox(o.value) === norm)
return match ? match.value : '__custom__'
},
set(v) {
if (v === '__auto__') {
if (!osEditingOrg.value) prefs.autoBbox = true
osBboxCustom.value = false
return
}
// Any explicit choice leaves auto mode (personal scope only).
if (!osEditingOrg.value) prefs.autoBbox = false
if (v === '__custom__') { osBboxCustom.value = true; return }
osBboxCustom.value = false
osForm.bbox = v
},
})
const osBboxManual = computed(() => osBboxPreset.value === '__custom__')
const osBboxIsAuto = computed(() => osBboxPreset.value === '__auto__')
// Superadmin edits the global layer in the API panel — read-only in the Web App.
const osReadOnly = computed(() => os.isSuperadmin)
// The scope key currently in view (superadmin always sees the single read-only view).
const osScopeKey = computed(() => (os.isSuperadmin ? 'user' : osScope.value))
const osScopeData = computed(() => os.scopes[osScopeKey.value] || { editableLayer: 'user', fields: {} })
// True when the admin is editing organization-wide settings that apply to all users.
const osEditingOrg = computed(() => osScopeKey.value === 'org')
function osField(k) {
return osScopeData.value.fields[k] || { effective: '', own: '', source: 'unset', locked: false }
}
function osLocked(k) {
return osReadOnly.value || osField(k).locked
}
function osSourceLabel(k) {
const src = osField(k).source
if (src === 'global') return 'Set by administrator'
if (src === 'org') return 'Set by your organization'
return ''
}
// Load the editable inputs from the current scope's own-layer values.
function fillOsForm() {
osForm.clientId = osField('clientId').own || ''
osForm.clientSecret = osField('clientSecret').own || ''
osForm.plan = osField('plan').own || ''
osForm.bbox = osField('bbox').own || ''
// Show the preset dropdown; the getter falls back to Custom for non-preset values.
osBboxCustom.value = false
}
function applyOpenSkyView(body) {
os.available = !!body.available
os.orgEnabled = body.orgEnabled !== false // default enabled
os.allowAnonymous = !!body.allowAnonymous
os.enabled = !!body.enabled
os.canEditOrg = !!body.canEditOrg
os.isSuperadmin = !!body.isSuperadmin
os.scopes = body.scopes || {}
// Keep the selected scope valid (fall back to personal when org isn't available).
if (osScope.value === 'org' && !os.canEditOrg) osScope.value = 'user'
fillOsForm()
os.loaded = true
}
// Re-fill the form when an admin flips between personal and organization scope.
watch(osScope, () => { osMsg.value = ''; fillOsForm() })
async function loadOpenSky() {
restoreOsHealth() // show the last known credit balance immediately, before any probe
const { ok, body } = await getOpenSky()
if (ok) applyOpenSkyView(body)
}
async function toggleOpenSky(v) {
// In the organization scope the switch is the org master gate; otherwise it's
// the caller's personal enable flag.
const org = osEditingOrg.value
if (org) os.orgEnabled = v
else os.enabled = v
const { ok, body } = await saveOpenSky(org ? { scope: 'org', enabled: v } : { scope: 'user', enabled: v })
if (ok) {
applyOpenSkyView(body)
flash(org ? (v ? 'OpenSky enabled for your organization.' : 'OpenSky disabled for your organization.')
: (v ? 'OpenSky enabled.' : 'OpenSky disabled.'))
} else {
if (org) os.orgEnabled = !v // revert on failure
else os.enabled = !v
flash(body.error || 'Could not update.')
}
}
async function saveOpenSkySettings() {
osMsg.value = ''
osSaving.value = true
const config = {}
for (const k of ['clientId', 'clientSecret', 'plan', 'bbox']) {
if (!osLocked(k)) config[k] = osForm[k]
}
const payload = { scope: osScopeKey.value, config }
// The enable flag is personal — only send it from the "My settings" scope.
if (!osEditingOrg.value) payload.enabled = os.enabled
const { ok, body } = await saveOpenSky(payload)
osSaving.value = false
if (!ok) {
osMsg.value = body.error || 'Could not save settings.'
return
}
applyOpenSkyView(body)
flash(osEditingOrg.value ? 'Organization OpenSky settings saved.' : 'OpenSky settings saved.')
}
// Test-connection "probe area": an optional bounding box used only for the
// Test connection probe (to check an area's credit cost) without touching the
// saved default. Full country list (World / Continents / all countries) + Custom.
const OS_PROBE_GROUPS = [
{ label: 'World', options: [{ value: '-90,-180,90,180', label: 'World' }] },
{ label: 'Continents', options: [
{ value: '34,-25,72,45', label: 'Europe' },
{ value: '-35,-18,38,52', label: 'Africa' },
{ value: '5,25,82,180', label: 'Asia' },
{ value: '7,-168,72,-52', label: 'North America' },
{ value: '-56,-82,13,-34', label: 'South America' },
{ value: '-48,110,-10,180', label: 'Oceania' },
] },
...countryGroups(),
]
const OS_PROBE_FLAT = OS_PROBE_GROUPS.flatMap((g) => g.options)
const osProbeBbox = ref('') // '' = use the saved default bounding box
const osProbeCustom = ref(false)
const osProbeSelect = computed({
get() {
if (osProbeCustom.value) return '__custom__'
if (!osProbeBbox.value) return '__default__'
const norm = osNormBbox(osProbeBbox.value)
const m = norm && OS_PROBE_FLAT.find((o) => osNormBbox(o.value) === norm)
return m ? m.value : '__custom__'
},
set(v) {
if (v === '__default__') { osProbeCustom.value = false; osProbeBbox.value = ''; return }
if (v === '__custom__') { osProbeCustom.value = true; return }
osProbeCustom.value = false
osProbeBbox.value = v
},
})
const osProbeManual = computed(() => osProbeSelect.value === '__custom__')
async function testOpenSkyConnection() {
osTesting.value = true
osHealth.value = null
const { ok, body } = await testOpenSky((osProbeBbox.value || '').trim() || undefined)
osTesting.value = false
osHealth.value = ok && body.health ? body.health : { status: 'down', detail: body.error || 'Probe failed.' }
osHealthTs.value = Date.now()
persistOsHealth()
}
function osHealthClass(status) {
return status === 'ok' ? badgeClass.success : status === 'degraded' ? badgeClass.warning : badgeClass.danger
}
/* ---------- integrations: File transfer (FTP/SFTP) ---------- */
// Same three-layer cascade as OpenSky (global → org → user); every connection
// field resolves together, only basePath cascades on its own.
const ft = reactive({
loaded: false,
available: false,
orgEnabled: true, // org master switch (admins toggle it; gates the org's users)
enabled: false, // personal opt-in
canEditOrg: false,
isSuperadmin: false,
scopes: {}, // { user: { editableLayer, fields }, org?: {...} }
})
const ftScope = ref('user')
// All editable fields; secrets are prefilled with the mask and preserved on save.
const FT_KEYS = ['protocol', 'host', 'port', 'username', 'password', 'privateKey', 'keyPassphrase', 'hostKeyFingerprint', 'insecureSkipVerify', 'basePath']
const ftForm = reactive(Object.fromEntries(FT_KEYS.map((k) => [k, ''])))
const ftMsg = ref('')
const ftSaving = ref(false)
const ftTesting = ref(false)
const ftHealth = ref(null) // { status, detail, latencyMs }
const ftHealthTs = ref(null)
const FT_HEALTH_KEY = 'pv.filetransfer.health'
const FT_PROTO_OPTS = [
{ value: 'sftp', label: 'SFTP' },
{ value: 'ftps', label: 'FTPS' },
{ value: 'ftp', label: 'FTP' },
]
const FT_TLS_OPTS = [
{ value: 'false', label: 'Verify certificate' },
{ value: 'true', label: 'Skip verification' },
]
const ftReadOnly = computed(() => ft.isSuperadmin)
const ftScopeKey = computed(() => (ft.isSuperadmin ? 'user' : ftScope.value))
const ftScopeData = computed(() => ft.scopes[ftScopeKey.value] || { editableLayer: 'user', fields: {} })
const ftEditingOrg = computed(() => ftScopeKey.value === 'org')
// Effective protocol in view — drives which auth fields are shown.
const ftProto = computed(() => (ftLocked('protocol') ? ftField('protocol').effective : ftForm.protocol) || 'sftp')
function ftField(k) {
return ftScopeData.value.fields[k] || { effective: '', own: '', source: 'unset', locked: false }
}
function ftLocked(k) {
return ftReadOnly.value || ftField(k).locked
}
function ftSourceLabel(k) {
const src = ftField(k).source
if (src === 'global') return 'Set by administrator'
if (src === 'org') return 'Set by your organization'
return ''
}
function ftProtoLabel(v) {
return (FT_PROTO_OPTS.find((o) => o.value === v) || {}).label || v || '—'
}
function fillFtForm() {
for (const k of FT_KEYS) ftForm[k] = ftField(k).own || ''
if (!ftForm.protocol) ftForm.protocol = 'sftp'
if (!ftForm.insecureSkipVerify) ftForm.insecureSkipVerify = 'false'
}
function applyFileTransferView(body) {
ft.available = !!body.available
ft.orgEnabled = body.orgEnabled !== false // default enabled
ft.enabled = !!body.enabled
ft.canEditOrg = !!body.canEditOrg
ft.isSuperadmin = !!body.isSuperadmin
ft.scopes = body.scopes || {}
if (ftScope.value === 'org' && !ft.canEditOrg) ftScope.value = 'user'
fillFtForm()
ft.loaded = true
}
watch(ftScope, () => { ftMsg.value = ''; fillFtForm() })
function ftHealthAgo() {
if (!ftHealthTs.value) return ''
const s = Math.max(0, Math.round((Date.now() - ftHealthTs.value) / 1000))
if (s < 60) return 'just now'
const m = Math.round(s / 60)
if (m < 60) return `${m} min ago`
const h = Math.round(m / 60)
if (h < 24) return `${h} h ago`
return `${Math.round(h / 24)} d ago`
}
function persistFtHealth() {
try {
if (ftHealth.value) localStorage.setItem(FT_HEALTH_KEY, JSON.stringify({ health: ftHealth.value, ts: ftHealthTs.value }))
} catch { /* storage unavailable — non-fatal */ }
}
function restoreFtHealth() {
try {
const raw = localStorage.getItem(FT_HEALTH_KEY)
if (!raw) return
const d = JSON.parse(raw)
if (d && d.health) { ftHealth.value = d.health; ftHealthTs.value = d.ts || null }
} catch { /* ignore corrupt cache */ }
}
async function loadFileTransfer() {
restoreFtHealth()
const { ok, body } = await getFileTransfer()
if (ok) applyFileTransferView(body)
}
async function toggleFileTransfer(v) {
const org = ftEditingOrg.value
if (org) ft.orgEnabled = v
else ft.enabled = v
const { ok, body } = await saveFileTransfer(org ? { scope: 'org', enabled: v } : { scope: 'user', enabled: v })
if (ok) {
applyFileTransferView(body)
flash(org ? (v ? 'File transfer enabled for your organization.' : 'File transfer disabled for your organization.')
: (v ? 'File transfer enabled.' : 'File transfer disabled.'))
} else {
if (org) ft.orgEnabled = !v
else ft.enabled = !v
flash(body.error || 'Could not update.')
}
}
async function saveFileTransferSettings() {
ftMsg.value = ''
ftSaving.value = true
const config = {}
for (const k of FT_KEYS) {
if (!ftLocked(k)) config[k] = ftForm[k]
}
const payload = { scope: ftScopeKey.value, config }
if (!ftEditingOrg.value) payload.enabled = ft.enabled
const { ok, body } = await saveFileTransfer(payload)
ftSaving.value = false
if (!ok) {
ftMsg.value = body.error || 'Could not save settings.'
return
}
applyFileTransferView(body)
flash(ftEditingOrg.value ? 'Organization file-transfer settings saved.' : 'File-transfer settings saved.')
}
async function testFileTransferConnection() {
ftTesting.value = true
ftHealth.value = null
const { ok, body } = await testFileTransfer()
ftTesting.value = false
ftHealth.value = ok && body.health ? body.health : { status: 'down', detail: body.error || 'Probe failed.' }
ftHealthTs.value = Date.now()
persistFtHealth()
}
function ftHealthClass(status) {
return status === 'ok' ? badgeClass.success : status === 'degraded' ? badgeClass.warning : badgeClass.danger
}
/* ---------- integrations: WebDAV ---------- */
// Same three-layer cascade as OpenSky (global → org → user); every connection
// field resolves together, only basePath cascades on its own — mirrors filetransfer.
const wd = reactive({
loaded: false,
available: false,
orgEnabled: true, // org master switch (admins toggle it; gates the org's users)
enabled: false, // personal opt-in
canEditOrg: false,
isSuperadmin: false,
scopes: {}, // { user: { editableLayer, fields }, org?: {...} }
})
const wdScope = ref('user')
// All editable fields; secrets are prefilled with the mask and preserved on save.
const WD_KEYS = ['baseURL', 'username', 'password', 'insecureSkipVerify', 'basePath']
const wdForm = reactive(Object.fromEntries(WD_KEYS.map((k) => [k, ''])))
const wdMsg = ref('')
const wdSaving = ref(false)
const wdTesting = ref(false)
const wdHealth = ref(null) // { status, detail, latencyMs }
const wdHealthTs = ref(null)
const WD_HEALTH_KEY = 'pv.webdav.health'
const WD_TLS_OPTS = [
{ value: 'false', label: 'Verify certificate' },
{ value: 'true', label: 'Skip verification' },
]
const wdReadOnly = computed(() => wd.isSuperadmin)
const wdScopeKey = computed(() => (wd.isSuperadmin ? 'user' : wdScope.value))
const wdScopeData = computed(() => wd.scopes[wdScopeKey.value] || { editableLayer: 'user', fields: {} })
const wdEditingOrg = computed(() => wdScopeKey.value === 'org')
function wdField(k) {
return wdScopeData.value.fields[k] || { effective: '', own: '', source: 'unset', locked: false }
}
function wdLocked(k) {
return wdReadOnly.value || wdField(k).locked
}
function wdSourceLabel(k) {
const src = wdField(k).source
if (src === 'global') return 'Set by administrator'
if (src === 'org') return 'Set by your organization'
return ''
}
function fillWdForm() {
for (const k of WD_KEYS) wdForm[k] = wdField(k).own || ''
if (!wdForm.insecureSkipVerify) wdForm.insecureSkipVerify = 'false'
}
function applyWebDavView(body) {
wd.available = !!body.available
wd.orgEnabled = body.orgEnabled !== false // default enabled
wd.enabled = !!body.enabled
wd.canEditOrg = !!body.canEditOrg
wd.isSuperadmin = !!body.isSuperadmin
wd.scopes = body.scopes || {}
if (wdScope.value === 'org' && !wd.canEditOrg) wdScope.value = 'user'
fillWdForm()
wd.loaded = true
}
watch(wdScope, () => { wdMsg.value = ''; fillWdForm() })
function wdHealthAgo() {
if (!wdHealthTs.value) return ''
const s = Math.max(0, Math.round((Date.now() - wdHealthTs.value) / 1000))
if (s < 60) return 'just now'
const m = Math.round(s / 60)
if (m < 60) return `${m} min ago`
const h = Math.round(m / 60)
if (h < 24) return `${h} h ago`
return `${Math.round(h / 24)} d ago`
}
function persistWdHealth() {
try {
if (wdHealth.value) localStorage.setItem(WD_HEALTH_KEY, JSON.stringify({ health: wdHealth.value, ts: wdHealthTs.value }))
} catch { /* storage unavailable — non-fatal */ }
}
function restoreWdHealth() {
try {
const raw = localStorage.getItem(WD_HEALTH_KEY)
if (!raw) return
const d = JSON.parse(raw)
if (d && d.health) { wdHealth.value = d.health; wdHealthTs.value = d.ts || null }
} catch { /* ignore corrupt cache */ }
}
async function loadWebDav() {
restoreWdHealth()
const { ok, body } = await getWebDav()
if (ok) applyWebDavView(body)
}
async function toggleWebDav(v) {
const org = wdEditingOrg.value
if (org) wd.orgEnabled = v
else wd.enabled = v
const { ok, body } = await saveWebDav(org ? { scope: 'org', enabled: v } : { scope: 'user', enabled: v })
if (ok) {
applyWebDavView(body)
flash(org ? (v ? 'WebDAV enabled for your organization.' : 'WebDAV disabled for your organization.')
: (v ? 'WebDAV enabled.' : 'WebDAV disabled.'))
} else {
if (org) wd.orgEnabled = !v
else wd.enabled = !v
flash(body.error || 'Could not update.')
}
}
async function saveWebDavSettings() {
wdMsg.value = ''
wdSaving.value = true
const config = {}
for (const k of WD_KEYS) {
if (!wdLocked(k)) config[k] = wdForm[k]
}
const payload = { scope: wdScopeKey.value, config }
if (!wdEditingOrg.value) payload.enabled = wd.enabled
const { ok, body } = await saveWebDav(payload)
wdSaving.value = false
if (!ok) {
wdMsg.value = body.error || 'Could not save settings.'
return
}
applyWebDavView(body)
flash(wdEditingOrg.value ? 'Organization WebDAV settings saved.' : 'WebDAV settings saved.')
}
async function testWebDavConnection() {
wdTesting.value = true
wdHealth.value = null
const { ok, body } = await testWebDav()
wdTesting.value = false
wdHealth.value = ok && body.health ? body.health : { status: 'down', detail: body.error || 'Probe failed.' }
wdHealthTs.value = Date.now()
persistWdHealth()
}
function wdHealthClass(status) {
return status === 'ok' ? badgeClass.success : status === 'degraded' ? badgeClass.warning : badgeClass.danger
}
/* ---------- integrations: OpenWeather ---------- */
// Same three-layer cascade as OpenSky (global → org → user); every field resolves
// independently (the API key is a lone secret, so no field group is needed).
const ow = reactive({
loaded: false,
available: false,
orgEnabled: true, // org master switch (admins toggle it; gates the org's users)
enabled: false, // personal opt-in
canEditOrg: false,
isSuperadmin: false,
scopes: {}, // { user: { editableLayer, fields }, org?: {...} }
})
const owScope = ref('user')
// All editable fields; the API key is prefilled with the mask and preserved on save.
const OW_KEYS = ['apiKey', 'units', 'lat', 'lon', 'lang', 'callsPerMinute']
const owForm = reactive(Object.fromEntries(OW_KEYS.map((k) => [k, ''])))
const owMsg = ref('')
const owSaving = ref(false)
const owTesting = ref(false)
const owHealth = ref(null) // { status, detail, latencyMs }
const owHealthTs = ref(null)
const OW_HEALTH_KEY = 'pv.openweather.health'
const OW_UNITS_OPTS = [
{ value: '', label: 'Not set' },
{ value: 'metric', label: 'Metric (°C)' },
{ value: 'imperial', label: 'Imperial (°F)' },
{ value: 'standard', label: 'Standard (K)' },
]
const owReadOnly = computed(() => ow.isSuperadmin)
const owScopeKey = computed(() => (ow.isSuperadmin ? 'user' : owScope.value))
const owScopeData = computed(() => ow.scopes[owScopeKey.value] || { editableLayer: 'user', fields: {} })
const owEditingOrg = computed(() => owScopeKey.value === 'org')
function owField(k) {
return owScopeData.value.fields[k] || { effective: '', own: '', source: 'unset', locked: false }
}
function owLocked(k) {
return owReadOnly.value || owField(k).locked
}
function owSourceLabel(k) {
const src = owField(k).source
if (src === 'global') return 'Set by administrator'
if (src === 'org') return 'Set by your organization'
return ''
}
function fillOwForm() {
for (const k of OW_KEYS) owForm[k] = owField(k).own || ''
}
function applyOpenWeatherView(body) {
ow.available = !!body.available
ow.orgEnabled = body.orgEnabled !== false // default enabled
ow.enabled = !!body.enabled
ow.canEditOrg = !!body.canEditOrg
ow.isSuperadmin = !!body.isSuperadmin
ow.scopes = body.scopes || {}
if (owScope.value === 'org' && !ow.canEditOrg) owScope.value = 'user'
fillOwForm()
ow.loaded = true
}
watch(owScope, () => { owMsg.value = ''; fillOwForm() })
function owHealthAgo() {
if (!owHealthTs.value) return ''
const s = Math.max(0, Math.round((Date.now() - owHealthTs.value) / 1000))
if (s < 60) return 'just now'
const m = Math.round(s / 60)
if (m < 60) return `${m} min ago`
const h = Math.round(m / 60)
if (h < 24) return `${h} h ago`
return `${Math.round(h / 24)} d ago`
}
function persistOwHealth() {
try {
if (owHealth.value) localStorage.setItem(OW_HEALTH_KEY, JSON.stringify({ health: owHealth.value, ts: owHealthTs.value }))
} catch { /* storage unavailable — non-fatal */ }
}
function restoreOwHealth() {
try {
const raw = localStorage.getItem(OW_HEALTH_KEY)
if (!raw) return
const d = JSON.parse(raw)
if (d && d.health) { owHealth.value = d.health; owHealthTs.value = d.ts || null }
} catch { /* ignore corrupt cache */ }
}
async function loadOpenWeather() {
restoreOwHealth()
const { ok, body } = await getOpenWeather()
if (ok) applyOpenWeatherView(body)
}
async function toggleOpenWeather(v) {
const org = owEditingOrg.value
if (org) ow.orgEnabled = v
else ow.enabled = v
const { ok, body } = await saveOpenWeather(org ? { scope: 'org', enabled: v } : { scope: 'user', enabled: v })
if (ok) {
applyOpenWeatherView(body)
flash(org ? (v ? 'OpenWeather enabled for your organization.' : 'OpenWeather disabled for your organization.')
: (v ? 'OpenWeather enabled.' : 'OpenWeather disabled.'))
} else {
if (org) ow.orgEnabled = !v
else ow.enabled = !v
flash(body.error || 'Could not update.')
}
}
async function saveOpenWeatherSettings() {
owMsg.value = ''
owSaving.value = true
const config = {}
for (const k of OW_KEYS) {
if (!owLocked(k)) config[k] = owForm[k]
}
const payload = { scope: owScopeKey.value, config }
if (!owEditingOrg.value) payload.enabled = ow.enabled
const { ok, body } = await saveOpenWeather(payload)
owSaving.value = false
if (!ok) {
owMsg.value = body.error || 'Could not save settings.'
return
}
applyOpenWeatherView(body)
flash(owEditingOrg.value ? 'Organization OpenWeather settings saved.' : 'OpenWeather settings saved.')
}
async function testOpenWeatherConnection() {
owTesting.value = true
owHealth.value = null
const { ok, body } = await testOpenWeather()
owTesting.value = false
owHealth.value = ok && body.health ? body.health : { status: 'down', detail: body.error || 'Probe failed.' }
owHealthTs.value = Date.now()
persistOwHealth()
}
function owHealthClass(status) {
return status === 'ok' ? badgeClass.success : status === 'degraded' ? badgeClass.warning : badgeClass.danger
}
// App-side call usage from the last probe (null until one runs). OpenWeather does
// not report remaining quota, so these are the calls PilotVault itself has made.
const owUsage = computed(() => (owHealth.value && owHealth.value.usage) || null)
// Fraction of the per-minute limit consumed (null when no limit is known).
const owUsagePct = computed(() => {
const u = owUsage.value
if (!u || !u.minuteLimit) return null
return Math.max(0, Math.min(100, Math.round((u.minuteUsed / u.minuteLimit) * 100)))
})
// Bar colour tracks how close to the per-minute limit we are (fills as usage grows).
const owUsageBarClass = computed(() => {
const p = owUsagePct.value
if (p == null) return 'bg-accent'
if (p >= 90) return 'bg-danger'
if (p >= 70) return 'bg-amber'
return 'bg-success'
})
/* ---------- integrations: Local storage (host filesystem) ---------- */
// Same three-layer cascade as OpenSky (global → org → user), but the folder is not
// editable: it is derived from identity so each org-less user gets a private folder
// and each organization a shared one. The one cascading tunable is readOnly.
const ls = reactive({
loaded: false,
available: false,
orgEnabled: true, // org master switch
enabled: false, // personal opt-in
canEditOrg: false,
isSuperadmin: false,
isOrgUser: false,
mounts: [], // [{ id, label, path, kind }] — derived isolated folders
privateFolder: false, // the user's own private-folder opt-in
privateEnabled: false,// effective (org may gate it off)
allowPrivate: true, // org policy: members may create private folders
rootConfigured: false,
scopes: {},
})
const lsScope = ref('user')
const lsReadOnlyForm = ref('') // '' inherit | 'false' | 'true'
const lsMsg = ref('')
const lsSaving = ref(false)
const lsTesting = ref(false)
const lsHealth = ref(null)
const lsHealthTs = ref(null)
const lsMountHealth = ref({}) // id -> { status, detail }
const LS_HEALTH_KEY = 'pv.localstorage.health'
const LS_RO_OPTS = [
{ value: '', label: 'Inherit' },
{ value: 'false', label: 'Read-write' },
{ value: 'true', label: 'Read-only' },
]
const lsReadOnly = computed(() => ls.isSuperadmin) // superadmin: read-only view here
const lsScopeKey = computed(() => (ls.isSuperadmin ? 'user' : lsScope.value))
const lsScopeData = computed(() => ls.scopes[lsScopeKey.value] || { editableLayer: 'user', fields: {} })
const lsEditingOrg = computed(() => lsScopeKey.value === 'org')
function lsField(k) {
return lsScopeData.value.fields[k] || { effective: '', own: '', source: 'unset', locked: false }
}
function lsLocked(k) {
return lsReadOnly.value || lsField(k).locked
}
function lsSourceLabel(k) {
const src = lsField(k).source
if (src === 'global') return 'Set by administrator'
if (src === 'org') return 'Set by your organization'
return ''
}
function lsRoLabel(v) {
return (LS_RO_OPTS.find((o) => o.value === v) || {}).label || 'Inherit'
}
function fillLsForm() {
lsReadOnlyForm.value = lsField('readOnly').own || ''
}
function applyLocalStorageView(body) {
ls.available = !!body.available
ls.orgEnabled = body.orgEnabled !== false
ls.enabled = !!body.enabled
ls.canEditOrg = !!body.canEditOrg
ls.isSuperadmin = !!body.isSuperadmin
ls.isOrgUser = !!body.isOrgUser
ls.mounts = Array.isArray(body.mounts) ? body.mounts : []
ls.privateFolder = !!body.privateFolder
ls.privateEnabled = !!body.privateEnabled
ls.allowPrivate = body.allowPrivate !== false
ls.rootConfigured = !!body.rootConfigured
ls.scopes = body.scopes || {}
if (lsScope.value === 'org' && !ls.canEditOrg) lsScope.value = 'user'
fillLsForm()
ls.loaded = true
}
watch(lsScope, () => { lsMsg.value = ''; fillLsForm() })
function lsHealthAgo() {
if (!lsHealthTs.value) return ''
const s = Math.max(0, Math.round((Date.now() - lsHealthTs.value) / 1000))
if (s < 60) return 'just now'
const m = Math.round(s / 60)
if (m < 60) return `${m} min ago`
const h = Math.round(m / 60)
if (h < 24) return `${h} h ago`
return `${Math.round(h / 24)} d ago`
}
function persistLsHealth() {
try {
if (lsHealth.value) localStorage.setItem(LS_HEALTH_KEY, JSON.stringify({ health: lsHealth.value, ts: lsHealthTs.value }))
} catch { /* storage unavailable — non-fatal */ }
}
function restoreLsHealth() {
try {
const raw = localStorage.getItem(LS_HEALTH_KEY)
if (!raw) return
const d = JSON.parse(raw)
if (d && d.health) { lsHealth.value = d.health; lsHealthTs.value = d.ts || null }
} catch { /* ignore corrupt cache */ }
}
async function loadLocalStorage() {
restoreLsHealth()
const { ok, body } = await getLocalStorage()
if (ok) applyLocalStorageView(body)
}
async function toggleLocalStorage(v) {
const org = lsEditingOrg.value
if (org) ls.orgEnabled = v
else ls.enabled = v
const { ok, body } = await saveLocalStorage(org ? { scope: 'org', enabled: v } : { scope: 'user', enabled: v })
if (ok) {
applyLocalStorageView(body)
flash(org ? (v ? 'Local storage enabled for your organization.' : 'Local storage disabled for your organization.')
: (v ? 'Local storage enabled.' : 'Local storage disabled.'))
} else {
if (org) ls.orgEnabled = !v
else ls.enabled = !v
flash(body.error || 'Could not update.')
}
}
// Personal opt-in for a private folder inside the org folder (user scope).
async function togglePrivateFolder(v) {
ls.privateFolder = v
const { ok, body } = await saveLocalStorage({ scope: 'user', privateFolder: v })
if (ok) {
applyLocalStorageView(body)
flash(v ? 'Private folder enabled.' : 'Private folder disabled.')
} else {
ls.privateFolder = !v
flash(body.error || 'Could not update.')
}
}
// Org policy: allow members to create private folders (org scope).
async function toggleAllowPrivate(v) {
ls.allowPrivate = v
const { ok, body } = await saveLocalStorage({ scope: 'org', allowPrivate: v })
if (ok) {
applyLocalStorageView(body)
flash(v ? 'Members may now create private folders.' : 'Private folders disabled for your organization.')
} else {
ls.allowPrivate = !v
flash(body.error || 'Could not update.')
}
}
async function saveLocalStorageSettings() {
lsMsg.value = ''
lsSaving.value = true
const config = {}
if (!lsLocked('readOnly')) config.readOnly = lsReadOnlyForm.value
const payload = { scope: lsScopeKey.value, config }
if (!lsEditingOrg.value) payload.enabled = ls.enabled
const { ok, body } = await saveLocalStorage(payload)
lsSaving.value = false
if (!ok) {
lsMsg.value = body.error || 'Could not save settings.'
return
}
applyLocalStorageView(body)
flash(lsEditingOrg.value ? 'Organization local-storage settings saved.' : 'Local-storage settings saved.')
}
async function testLocalStorageConnection() {
lsTesting.value = true
lsHealth.value = null
lsMountHealth.value = {}
const { ok, body } = await testLocalStorage()
lsTesting.value = false
lsHealth.value = ok && body.health ? body.health : { status: 'down', detail: body.error || 'Probe failed.' }
const perMount = {}
if (Array.isArray(body.mounts)) for (const m of body.mounts) perMount[m.id] = { status: m.status, detail: m.detail }
lsMountHealth.value = perMount
lsHealthTs.value = Date.now()
persistLsHealth()
}
function lsHealthClass(status) {
return status === 'ok' ? badgeClass.success : status === 'degraded' ? badgeClass.warning : badgeClass.danger
}
/* ---------- integrations: plugin tabs ---------- */
// Plugins are grouped into category tabs within the Integrations section.
const INTEGRATION_TABS = [
{ id: 'apis-external', label: 'APIs — External', icon: 'globe' },
{ id: 'drives-external', label: 'Drives — External', icon: 'server' },
{ id: 'drives-local', label: 'Drives — Local', icon: 'monitor' },
]
const integrationTab = ref('apis-external')
// A plugin group is shown when its tab is active; search mode reveals every group.
function integrationTabShown(id) {
return searching.value || integrationTab.value === id
}
/* ---------- account ---------- */
const savedFlash = ref('')
let flashTimer = null
function flash(msg) {
savedFlash.value = msg
clearTimeout(flashTimer)
flashTimer = setTimeout(() => (savedFlash.value = ''), 2200)
}
const pw = reactive({ current: '', next: '', confirm: '' })
const pwMsg = ref('')
const pwOk = ref(false)
function changePassword() {
pwOk.value = false
if (!pw.current) return (pwMsg.value = 'Enter your current password.')
if (pw.next.length < 8) return (pwMsg.value = 'New password must be at least 8 characters.')
if (pw.next !== pw.confirm) return (pwMsg.value = 'New passwords do not match.')
// No account endpoint yet — the BFF only proxies login. Report honestly.
pwMsg.value = 'Validated. Connecting to the account service is pending — no password endpoint yet.'
pw.current = pw.next = pw.confirm = ''
}
const verifyMsg = ref('')
function resendVerification() {
verifyMsg.value = 'Verification link would be sent once the account service is wired up.'
}
/* ---------- profile ---------- */
function onAvatar(e) {
const file = e.target.files && e.target.files[0]
if (!file) return
if (file.size > 1.5 * 1024 * 1024) {
flash('Image too large (max ~1.5 MB).')
return
}
const reader = new FileReader()
reader.onload = () => {
prefs.avatar = String(reader.result)
flash('Photo updated.')
}
reader.readAsDataURL(file)
}
function removeAvatar() {
prefs.avatar = ''
flash('Photo removed.')
}
const initials = computed(() => {
const base = (prefs.displayName || prefs.name || props.email || 'PV').split('@')[0]
const parts = base.split(/[.\-_ ]+/).filter(Boolean)
return ((parts[0]?.[0] || 'P') + (parts[1]?.[0] || parts[0]?.[1] || 'V')).toUpperCase()
})
/* ---------- security: 2FA ---------- */
const twoFASetup = ref(false)
const twoFASecret = ref('')
const twoFACode = ref('')
const twoFAMsg = ref('')
const recoveryCodes = ref([])
function randBase32(len) {
const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
let s = ''
for (let i = 0; i < len; i++) s += A[Math.floor(Math.random() * A.length)]
return s
}
function beginTwoFA() {
twoFASecret.value = randBase32(16).replace(/(.{4})/g, '$1 ').trim()
twoFACode.value = ''
twoFAMsg.value = ''
twoFASetup.value = true
}
function confirmTwoFA() {
if (!/^\d{6}$/.test(twoFACode.value)) {
twoFAMsg.value = 'Enter the 6-digit code from your authenticator app.'
return
}
// Prototype: real verification needs the account service. Accept locally.
prefs.twoFactor = true
twoFASetup.value = false
recoveryCodes.value = Array.from({ length: 8 }, () => randBase32(4).toLowerCase() + '-' + randBase32(4).toLowerCase())
twoFAMsg.value = ''
}
function disableTwoFA() {
prefs.twoFactor = false
recoveryCodes.value = []
twoFASetup.value = false
}
/* ---------- security: sessions ---------- */
const ua = navigator.userAgent
function browserName() {
if (/Edg\//.test(ua)) return 'Edge'
if (/OPR\//.test(ua)) return 'Opera'
if (/Chrome\//.test(ua)) return 'Chrome'
if (/Firefox\//.test(ua)) return 'Firefox'
if (/Safari\//.test(ua)) return 'Safari'
return 'Browser'
}
function osName() {
if (/Windows/.test(ua)) return 'Windows'
if (/Mac OS X/.test(ua)) return 'macOS'
if (/Android/.test(ua)) return 'Android'
if (/iPhone|iPad/.test(ua)) return 'iOS'
if (/Linux/.test(ua)) return 'Linux'
return 'Unknown OS'
}
const sessionStart = Date.now()
/* ---------- user management (managers) ---------- */
const users = ref([])
const usersLoading = ref(false)
const usersErr = ref('')
const nu = reactive({ email: '', password: '', role: 'user', organization: '' })
const nuMsg = ref('')
const creating = ref(false)
const confirmId = ref('')
// Superadmins may also mint superadmins; admins are capped at user/admin.
const ROLE_OPTS = computed(() => {
const opts = [
{ value: 'user', label: 'User' },
{ value: 'admin', label: 'Admin' },
]
if (isSuperadmin.value) opts.push({ value: 'superadmin', label: 'Superadmin' })
return opts
})
/* organizations — loaded for any manager (used for name lookups + selectors) */
const orgs = ref([])
async function loadOrgs() {
if (!isManager.value) return
const res = await getOrgs()
if (res.ok) orgs.value = res.organizations.slice().sort((a, b) => a.name.localeCompare(b.name))
}
// Options for the org selector: superadmins can also pick "No organization".
const ORG_OPTS = computed(() => {
const opts = orgs.value.map((o) => ({ value: o.id, label: o.name }))
if (isSuperadmin.value) opts.unshift({ value: '', label: 'No organization' })
return opts
})
async function loadUsers() {
if (!isManager.value) return
usersLoading.value = true
usersErr.value = ''
const res = await getUsers()
usersLoading.value = false
if (!res.ok) {
usersErr.value = res.status === 403 ? 'Manager role required.' : 'Could not load users.'
return
}
users.value = res.users.slice().sort((a, b) => a.email.localeCompare(b.email))
}
function firstPbError(body) {
try {
const d = body.data || {}
const k = Object.keys(d)[0]
return (k && d[k] && d[k].message) || body.message || body.error || 'Invalid input.'
} catch {
return body.error || 'Could not create user.'
}
}
async function addUser() {
nuMsg.value = ''
const email = nu.email.trim().toLowerCase()
if (!email.includes('@')) return (nuMsg.value = 'Enter a valid email.')
if (nu.password.length < 8) return (nuMsg.value = 'Password must be at least 8 characters.')
creating.value = true
// Admins are pinned to their own org server-side; the value sent is ignored for them.
const org = isSuperadmin.value ? nu.organization : props.organization
const { ok, body } = await createUser(email, nu.password, nu.role, org)
creating.value = false
if (!ok) return (nuMsg.value = firstPbError(body))
nu.email = ''
nu.password = ''
nu.role = 'user'
nu.organization = ''
flash('User created.')
loadUsers()
}
async function removeUser(u) {
const { ok, body } = await deleteUser(u.id)
confirmId.value = ''
if (!ok) return flash(body.error || 'Could not remove user.')
flash('User removed.')
loadUsers()
}
/* edit user */
const edit = reactive({ id: '', email: '', role: 'user', verified: false, password: '', organization: '' })
const editMsg = ref('')
const savingEdit = ref(false)
const editingSelf = computed(() => !!edit.id && edit.email === props.email)
function startEdit(u) {
confirmId.value = ''
edit.id = u.id
edit.email = u.email
edit.role = u.role || 'user'
edit.verified = !!u.verified
edit.password = ''
edit.organization = u.organization || ''
editMsg.value = ''
}
function cancelEdit() {
edit.id = ''
editMsg.value = ''
}
async function saveEdit() {
editMsg.value = ''
const email = edit.email.trim().toLowerCase()
if (!email.includes('@')) return (editMsg.value = 'Enter a valid email.')
if (edit.password && edit.password.length < 8)
return (editMsg.value = 'New password must be at least 8 characters (or leave blank).')
const changes = { email, role: edit.role, verified: edit.verified }
// Only a superadmin may reassign a user's organization.
if (isSuperadmin.value) changes.organization = edit.organization
if (edit.password) changes.password = edit.password
savingEdit.value = true
const { ok, body } = await updateUser(edit.id, changes)
savingEdit.value = false
if (!ok) return (editMsg.value = firstPbError(body))
flash('User updated.')
cancelEdit()
loadUsers()
}
/* ---------- organizations (superadmin only) ---------- */
const no = reactive({ name: '' })
const orgMsg = ref('')
const orgBusy = ref(false)
const orgConfirmId = ref('')
const orgEdit = reactive({ id: '', name: '' })
const orgEditMsg = ref('')
// Count members per org for the list + delete guard.
const orgMemberCount = computed(() => {
const m = {}
for (const u of users.value) if (u.organization) m[u.organization] = (m[u.organization] || 0) + 1
return m
})
async function addOrg() {
orgMsg.value = ''
const name = no.name.trim()
if (!name) return (orgMsg.value = 'Enter an organization name.')
orgBusy.value = true
const { ok, body } = await createOrg(name)
orgBusy.value = false
if (!ok) return (orgMsg.value = firstPbError(body))
no.name = ''
flash('Organization created.')
loadOrgs()
}
function startOrgEdit(o) {
orgConfirmId.value = ''
orgEdit.id = o.id
orgEdit.name = o.name
orgEditMsg.value = ''
}
function cancelOrgEdit() {
orgEdit.id = ''
orgEditMsg.value = ''
}
async function saveOrg() {
orgEditMsg.value = ''
const name = orgEdit.name.trim()
if (!name) return (orgEditMsg.value = 'Enter an organization name.')
const { ok, body } = await updateOrg(orgEdit.id, name)
if (!ok) return (orgEditMsg.value = firstPbError(body))
flash('Organization renamed.')
cancelOrgEdit()
loadOrgs()
loadUsers()
}
async function removeOrg(o) {
const { ok, body } = await deleteOrg(o.id)
orgConfirmId.value = ''
if (!ok) return flash(body.error || 'Could not delete organization.')
flash('Organization deleted.')
loadOrgs()
}
/* ---------- advanced: export / import ---------- */
function exportData() {
const payload = {
_app: 'PilotVault',
_kind: 'settings-export',
exportedAt: new Date().toISOString(),
email: props.email,
prefs: { ...prefs },
themeMode: themeMode.value,
}
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `pilotvault-settings-${new Date().toISOString().slice(0, 10)}.json`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
flash('Settings exported.')
}
const importMsg = ref('')
function onImport(e) {
const file = e.target.files && e.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
try {
const data = JSON.parse(String(reader.result))
const src = data.prefs || data
if (!importPrefs(src)) throw new Error('bad shape')
if (data.themeMode) setThemeMode(data.themeMode)
applyFontSize(prefs.fontSize)
applyReduceMotion(prefs.reduceMotion)
importMsg.value = 'Settings imported and applied.'
} catch {
importMsg.value = 'That file is not a valid PilotVault settings export.'
}
}
reader.readAsText(file)
e.target.value = ''
}
/* ---------- advanced: delete account (friction) ---------- */
const del = reactive({ understand: false, typed: '', cooldown: 0, armed: false, msg: '' })
let cooldownTimer = null
const delTarget = computed(() => props.email || 'DELETE MY ACCOUNT')
const delReady = computed(() => del.understand && del.typed === delTarget.value)
function arm() {
if (!delReady.value) return
del.armed = true
del.cooldown = 5
clearInterval(cooldownTimer)
cooldownTimer = setInterval(() => {
del.cooldown--
if (del.cooldown <= 0) clearInterval(cooldownTimer)
}, 1000)
}
// If the user edits the confirmation after arming, cancel the countdown.
watch(delReady, (ok) => {
if (!ok && del.armed) {
del.armed = false
del.cooldown = 0
clearInterval(cooldownTimer)
}
})
function deleteAccount() {
if (!del.armed || del.cooldown > 0) return
// No delete endpoint yet: clear all local state and sign out.
try {
localStorage.removeItem('pv_prefs')
} catch {
/* ignore */
}
del.msg = 'Account deletion requires the account service. Local data was cleared and you were signed out.'
setTimeout(() => emit('logout'), 900)
}
onMounted(() => {
clock = setInterval(() => (now.value = Date.now()), 1000)
loadOrgs()
loadUsers()
loadOpenSky()
loadFileTransfer()
loadWebDav()
loadOpenWeather()
loadLocalStorage()
})
onBeforeUnmount(() => {
clearInterval(clock)
clearInterval(cooldownTimer)
clearTimeout(flashTimer)
})
</script>
<template>
<div class="mx-auto max-w-[1280px] p-7">
<!-- header + search -->
<div class="mb-5 flex flex-wrap items-end justify-between gap-4">
<div>
<div class="eyebrow">Preferences</div>
<h2 class="mt-0.5 text-[22px] font-bold tracking-tightest text-ink">Settings</h2>
</div>
<div class="flex h-10 w-full max-w-[280px] items-center gap-2 rounded border border-line-strong bg-surface-1 px-3">
<Icon name="search" :size="16" class="text-ink-muted" />
<input
v-model="q"
placeholder="Search settings…"
class="w-full border-none bg-transparent text-sm text-ink outline-none placeholder:text-ink-muted"
/>
<button v-if="q" class="text-ink-muted hover:text-ink" aria-label="Clear search" @click="q = ''">
<Icon name="x" :size="15" />
</button>
</div>
</div>
<div class="grid grid-cols-[210px_1fr] gap-6 max-[760px]:grid-cols-1">
<!-- section nav -->
<nav v-show="!searching" class="flex flex-col gap-0.5 max-[760px]:flex-row max-[760px]:overflow-x-auto">
<button
v-for="s in SECTIONS"
:key="s.id"
class="flex items-center gap-2.5 rounded px-3 py-2.5 text-left text-sm transition"
:class="[
section === s.id
? s.danger ? 'bg-danger-soft font-semibold text-danger-fg' : 'bg-accent-soft font-semibold text-accent-soft-fg'
: s.danger ? 'font-medium text-danger-fg hover:bg-danger-soft' : 'font-medium text-ink-secondary hover:bg-surface-2',
]"
@click="section = s.id"
>
<Icon :name="s.icon" :size="17" />
<span class="whitespace-nowrap">{{ s.label }}</span>
</button>
</nav>
<!-- content -->
<div class="min-w-0">
<div v-if="searching && !visibleSections.length" class="panel p-10 text-center text-sm text-ink-muted">
No settings match “{{ q }}”.
</div>
<template v-for="s in visibleSections" :key="s.id">
<!-- section title shown in search mode -->
<div v-if="searching" class="eyebrow mb-2 mt-5 first:mt-0 flex items-center gap-2">
<Icon :name="s.icon" :size="14" /> {{ s.label }}
</div>
<!-- ============ ACCOUNT ============ -->
<div v-if="s.id === 'account'" class="panel mb-5 p-5">
<Row title="Full name" desc="Shown to your team on flights and audit logs." keywords="full name account">
<input v-model="prefs.name" class="field w-56" placeholder="Jane Operator" @blur="flash('Saved.')" />
</Row>
<Row title="Username" desc="Your unique handle within PilotVault." keywords="username handle">
<div class="flex items-center gap-1">
<span class="text-sm text-ink-muted">@</span>
<input v-model="prefs.username" class="field w-48" placeholder="jane" @blur="flash('Saved.')" />
</div>
</Row>
<Row title="Email address" desc="Used for sign-in and notifications." keywords="email verification verify">
<div class="flex items-center gap-2">
<span class="font-mono text-sm text-ink">{{ email || '—' }}</span>
<span class="inline-flex items-center gap-1 rounded-full bg-amber-soft px-2 py-0.5 text-[11px] font-semibold text-amber-fg">
<Icon name="mail" :size="12" /> Unverified
</span>
</div>
</Row>
<Row title="Role" desc="Your access level in PilotVault." keywords="role admin user superadmin access rights permissions">
<span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="roleBadge(role)"
>
<Icon :name="roleIcon(role)" :size="12" />{{ roleLabel(role) }}
</span>
</Row>
<Row title="Organization" desc="The organization your account belongs to." keywords="organization org tenant company">
<span class="text-sm" :class="organizationName ? 'text-ink' : 'text-ink-muted'">
{{ organizationName || (isSuperadmin ? 'All organizations' : 'None') }}
</span>
</Row>
<Row block title="Verify email" desc="Confirm ownership to enable password resets and alerts." keywords="verify email resend">
<button class="btn-ghost" @click="resendVerification">Send verification link</button>
<p v-if="verifyMsg" class="mt-2 text-xs text-ink-muted">{{ verifyMsg }}</p>
</Row>
<Row block title="Change password" desc="Use at least 8 characters." keywords="password change current new">
<div class="grid max-w-[420px] gap-2">
<input v-model="pw.current" type="password" class="field" placeholder="Current password" />
<input v-model="pw.next" type="password" class="field" placeholder="New password" />
<input v-model="pw.confirm" type="password" class="field" placeholder="Confirm new password" />
<div class="flex items-center gap-3">
<button class="btn-accent" @click="changePassword">Update password</button>
<span v-if="pwMsg" class="text-xs" :class="pwOk ? 'text-success-fg' : 'text-ink-muted'">{{ pwMsg }}</span>
</div>
</div>
</Row>
</div>
<!-- ============ APPEARANCE ============ -->
<div v-else-if="s.id === 'appearance'" class="panel mb-5 p-5">
<Row title="Theme" desc="Light, dark, or follow your system." keywords="theme light dark system appearance">
<Segmented v-model="themeSel" :options="THEME_OPTS" />
</Row>
<Row title="Font size" desc="Scales the entire interface for readability." keywords="font size accessibility text">
<Segmented v-model="prefs.fontSize" :options="FONT_OPTS" />
</Row>
<Row title="Reduce motion" desc="Minimise animations and transitions." keywords="reduce motion accessibility animation">
<Toggle v-model="prefs.reduceMotion" />
</Row>
<Row title="Language" desc="Interface language." keywords="language locale">
<select v-model="prefs.language" class="field w-48">
<option v-for="[v, l] in LANGS" :key="v" :value="v">{{ l }}</option>
</select>
</Row>
<Row title="Region" desc="Affects number, unit and date defaults." keywords="region country locale">
<select v-model="prefs.region" class="field w-48">
<option v-for="[v, l] in REGIONS" :key="v" :value="v">{{ l }}</option>
</select>
</Row>
<Row title="Date format" desc="How calendar dates are displayed." keywords="date format">
<select v-model="prefs.dateFormat" class="field w-48">
<option v-for="[v, l] in DATE_FMTS" :key="v" :value="v">{{ l }}</option>
</select>
</Row>
<Row title="Time format" desc="12- or 24-hour clock." keywords="time format clock 12 24 hour">
<Segmented v-model="prefs.timeFormat" :options="TIME_OPTS" />
</Row>
<Row title="Preview" desc="How timestamps appear across the app." keywords="preview date time">
<span class="font-mono text-sm text-ink">{{ preview }}</span>
</Row>
<p class="mt-3 text-xs text-ink-muted">
Language &amp; region are stored now; full localisation ships with the account service.
</p>
</div>
<!-- ============ INTEGRATIONS ============ -->
<div v-else-if="s.id === 'integrations'">
<!-- category tabs (hidden in search mode, which reveals every group) -->
<div v-if="!searching" class="mb-5 flex items-center gap-1 overflow-x-auto border-b border-line">
<button
v-for="t in INTEGRATION_TABS"
:key="t.id"
type="button"
class="-mb-px inline-flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition"
:class="integrationTab === t.id
? 'border-accent text-ink'
: 'border-transparent text-ink-secondary hover:text-ink'"
@click="integrationTab = t.id"
>
<Icon :name="t.icon" :size="16" />{{ t.label }}
</button>
</div>
<!-- ==== APIs — External ==== -->
<template v-if="integrationTabShown('apis-external')">
<!-- ---- OpenSky Network ---- -->
<div class="panel mb-5 p-5">
<!-- header -->
<div class="mb-4 flex items-start gap-3 border-b border-line pb-4">
<div class="grid h-10 w-10 place-items-center rounded-lg bg-accent-soft text-accent-soft-fg">
<Icon name="radio" :size="20" />
</div>
<div class="min-w-0">
<div class="text-sm font-semibold text-ink">OpenSky Network</div>
<div class="mt-0.5 text-xs text-ink-muted">
Live ADS-B aircraft data. Configure your own OAuth2 credentials, plan and default bounding box.
</div>
</div>
</div>
<!-- unavailable notice -->
<div v-if="os.loaded && !os.available" class="mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-xs text-ink-secondary">
<Icon name="lock" :size="14" class="mr-1 inline" />
OpenSky is currently disabled by your administrator. Contact them to enable it.
</div>
<!-- scope switch (admins manage personal + organization-wide settings) -->
<div v-if="os.canEditOrg" class="mb-4 flex flex-col gap-2 border-b border-line pb-4 sm:flex-row sm:items-center sm:justify-between">
<div class="text-xs text-ink-muted">Manage your own settings, or organization-wide settings that apply to every user.</div>
<Segmented v-model="osScope" :options="OS_SCOPE_OPTS" />
</div>
<!-- enable toggle: org master switch (Organization) or personal (My settings) -->
<Row v-if="osEditingOrg" title="Enable OpenSky (organization-wide)" desc="Turn OpenSky on or off for everyone in your organization." keywords="enable disable plugin opensky organization">
<Toggle :model-value="os.orgEnabled" :disabled="!os.available" @update:model-value="toggleOpenSky" />
</Row>
<Row v-else title="Enable OpenSky" desc="Turn the plugin on for your account in the Web App." keywords="enable disable plugin opensky">
<Toggle :model-value="os.enabled" :disabled="!os.available || !os.orgEnabled" @update:model-value="toggleOpenSky" />
</Row>
<!-- organization-disabled notice (personal scope) -->
<div v-if="!osEditingOrg && os.available && !os.orgEnabled" class="border-b border-line py-3 text-xs text-amber-fg">
<Icon name="lock" :size="13" class="mr-1 inline" />OpenSky is turned off for your organization<span v-if="os.canEditOrg"> — switch to <span class="font-semibold">Organization</span> to turn it back on</span>.
</div>
<!-- scope note -->
<div v-if="osEditingOrg" class="border-b border-line py-3 text-xs text-ink-muted">
<Icon name="users" :size="13" class="mr-1 inline" />These are organization-wide settings — they apply to everyone in
<span class="font-semibold text-ink-secondary">{{ organizationName || 'your organization' }}</span>. Leave a field blank to let each user choose their own; a value set here overrides the user's.
</div>
<div v-else-if="osReadOnly" class="border-b border-line py-3 text-xs text-ink-muted">
As a superadmin you manage the global OpenSky configuration in the API Server panel. The effective configuration is shown below.
</div>
<!-- credit usage (personal effective consumption — not shown in the org config tab) -->
<div v-if="os.available && !osEditingOrg" class="my-4 rounded-lg border border-line bg-surface-2 p-4" data-keywords="credits usage quota remaining daily allowance rate limit">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm font-semibold text-ink">
<Icon name="signal" :size="15" />Credit usage
</div>
<span v-if="osHealthTs" class="text-[11px] text-ink-muted">Checked {{ osHealthAgo() }}</span>
</div>
<template v-if="osCredits">
<!-- live remaining known -->
<template v-if="osCredits.remaining != null">
<div class="mt-2 flex items-baseline gap-1.5">
<span class="font-mono text-2xl font-semibold text-ink">{{ osFmt(osCredits.remaining) }}</span>
<span class="text-sm text-ink-muted">/ {{ osFmt(osCredits.daily) }} credits left today</span>
</div>
<div class="mt-2 h-2 w-full overflow-hidden rounded-full bg-line">
<div class="h-full rounded-full transition-all" :class="osCreditBarClass" :style="{ width: osCreditPct + '%' }"></div>
</div>
<div class="mt-2 text-xs text-ink-muted">
Used {{ osFmt(osCredits.daily - osCredits.remaining) }} today · {{ osCredits.probeCost }} credit{{ osCredits.probeCost === 1 ? '' : 's' }} per query · {{ osCredits.mode }}
</div>
</template>
<!-- remaining unknown (anonymous / no rate-limit header) -->
<template v-else>
<div class="mt-2 text-sm text-ink">Daily allowance: <span class="font-semibold">{{ osFmt(osCredits.daily) }}</span> credits</div>
<div class="mt-1 text-xs text-ink-muted">
{{ osCredits.probeCost }} credit{{ osCredits.probeCost === 1 ? '' : 's' }} per query · {{ osCredits.mode }}. OpenSky only reports live remaining credits for authenticated requests — add OAuth2 credentials below to track usage.
</div>
</template>
</template>
<div v-else class="mt-2 text-xs text-ink-muted">
Run <span class="font-semibold text-ink-secondary">Test connection</span> below to fetch your live OpenSky credit balance.
</div>
</div>
<!-- plan -->
<Row title="OpenSky plan" desc="Your account tier — sets the daily credit allowance." keywords="plan tier credits">
<template v-if="osLocked('plan')">
<span class="inline-flex items-center gap-2 text-sm text-ink">
{{ (OS_PLAN_OPTS.find((o) => o.value === osField('plan').effective) || {}).label || osField('plan').effective || '—' }}
<span v-if="osSourceLabel('plan')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ osSourceLabel('plan') }}</span>
</span>
</template>
<Segmented v-else v-model="osForm.plan" :options="OS_PLAN_OPTS" />
</Row>
<!-- bounding box -->
<Row title="Default bounding box" desc="Automatic follows your location; or pick a region, or enter lamin,lomin,lamax,lomax by hand." keywords="bounding box bbox area region country continent world europe custom coordinates automatic location drone">
<template v-if="osLocked('bbox')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ osBboxLabel(osField('bbox').effective) || osField('bbox').effective || '—' }}
<span v-if="osSourceLabel('bbox')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ osSourceLabel('bbox') }}</span>
</span>
</template>
<div v-else class="flex flex-col items-end gap-2">
<select v-model="osBboxPreset" class="field w-64">
<option v-if="!osEditingOrg" value="__auto__">Automatic (by location)</option>
<optgroup v-for="g in OS_BBOX_GROUPS" :key="g.label" :label="g.label">
<option v-for="o in g.options" :key="o.value" :value="o.value">{{ o.label }}</option>
</optgroup>
<option value="__custom__">Custom…</option>
</select>
<p v-if="osBboxIsAuto" class="w-64 text-right text-[11px] leading-snug text-ink-muted">
Live map follows drone location → your device location → your Region ({{ regionName }}).
</p>
<input v-if="osBboxManual" v-model="osForm.bbox" class="field w-64 font-mono" placeholder="50.5,3.2,53.7,7.3" />
</div>
</Row>
<!-- client id -->
<Row title="OAuth2 client ID" desc="Optional — leave blank for anonymous access (lower limits)." keywords="oauth client id credentials">
<template v-if="osLocked('clientId')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ osField('clientId').effective || '—' }}
<span v-if="osSourceLabel('clientId')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ osSourceLabel('clientId') }}</span>
</span>
</template>
<input v-else v-model="osForm.clientId" class="field w-64" placeholder="your-api-client" />
</Row>
<!-- client secret -->
<Row title="OAuth2 client secret" desc="Paired with the client ID for authenticated access." keywords="oauth client secret credentials password">
<template v-if="osLocked('clientSecret')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ osField('clientSecret').effective || '—' }}
<span v-if="osSourceLabel('clientSecret')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ osSourceLabel('clientSecret') }}</span>
</span>
</template>
<input v-else v-model="osForm.clientSecret" type="password" class="field w-64" placeholder="••••••••" />
</Row>
<!-- anonymous policy hint -->
<div v-if="os.available && !os.allowAnonymous" class="border-b border-line py-3 text-xs text-amber-fg">
Anonymous access is disabled by the administrator — OpenSky needs OAuth2 credentials from some layer to work.
</div>
<!-- actions -->
<div class="mt-4 flex flex-wrap items-center gap-3">
<button v-if="!osReadOnly" class="btn-accent" :disabled="osSaving || !os.available" @click="saveOpenSkySettings">
{{ osSaving ? 'Saving…' : osEditingOrg ? 'Save organization settings' : 'Save settings' }}
</button>
<div v-if="!osEditingOrg" class="flex items-center gap-2" title="Bounding box used for Test connection — smaller areas cost fewer OpenSky credits">
<label class="text-xs text-ink-muted">Test area</label>
<select v-model="osProbeSelect" class="field w-44">
<option value="__default__">Default bounding box</option>
<optgroup v-for="g in OS_PROBE_GROUPS" :key="g.label" :label="g.label">
<option v-for="o in g.options" :key="o.value" :value="o.value">{{ o.label }}</option>
</optgroup>
<option value="__custom__">Custom…</option>
</select>
<input v-if="osProbeManual" v-model="osProbeBbox" class="field w-44 font-mono" placeholder="lamin,lomin,lamax,lomax" />
</div>
<button v-if="!osEditingOrg" class="btn-ghost" :disabled="osTesting || !os.available" @click="testOpenSkyConnection">
{{ osTesting ? 'Testing…' : 'Test connection' }}
</button>
<span v-if="osMsg" class="text-xs text-danger-fg">{{ osMsg }}</span>
<span
v-if="osHealth && !osEditingOrg"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="osHealthClass(osHealth.status)"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ osHealth.detail || osHealth.status }}
</span>
</div>
</div>
<!-- ---- OpenWeather ---- -->
<div class="panel mb-5 p-5">
<!-- header -->
<div class="mb-4 flex items-start gap-3 border-b border-line pb-4">
<div class="grid h-10 w-10 place-items-center rounded-lg bg-accent-soft text-accent-soft-fg">
<Icon name="sun" :size="20" />
</div>
<div class="min-w-0">
<div class="text-sm font-semibold text-ink">OpenWeather</div>
<div class="mt-0.5 text-xs text-ink-muted">
Current conditions and forecast from the OpenWeather API. Configure the API key and default location your account uses.
</div>
</div>
</div>
<!-- unavailable notice -->
<div v-if="ow.loaded && !ow.available" class="mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-xs text-ink-secondary">
<Icon name="lock" :size="14" class="mr-1 inline" />
OpenWeather is currently disabled by your administrator. Contact them to enable it.
</div>
<!-- scope switch (admins manage personal + organization-wide settings) -->
<div v-if="ow.canEditOrg" class="mb-4 flex flex-col gap-2 border-b border-line pb-4 sm:flex-row sm:items-center sm:justify-between">
<div class="text-xs text-ink-muted">Manage your own settings, or organization-wide settings that apply to every user.</div>
<Segmented v-model="owScope" :options="OS_SCOPE_OPTS" />
</div>
<!-- enable toggle: org master switch (Organization) or personal (My settings) -->
<Row v-if="owEditingOrg" title="Enable OpenWeather (organization-wide)" desc="Turn it on or off for everyone in your organization." keywords="enable disable plugin openweather weather organization">
<Toggle :model-value="ow.orgEnabled" :disabled="!ow.available" @update:model-value="toggleOpenWeather" />
</Row>
<Row v-else title="Enable OpenWeather" desc="Turn the plugin on for your account in the Web App." keywords="enable disable plugin openweather weather">
<Toggle :model-value="ow.enabled" :disabled="!ow.available || !ow.orgEnabled" @update:model-value="toggleOpenWeather" />
</Row>
<!-- organization-disabled notice (personal scope) -->
<div v-if="!owEditingOrg && ow.available && !ow.orgEnabled" class="border-b border-line py-3 text-xs text-amber-fg">
<Icon name="lock" :size="13" class="mr-1 inline" />OpenWeather is turned off for your organization<span v-if="ow.canEditOrg"> — switch to <span class="font-semibold">Organization</span> to turn it back on</span>.
</div>
<!-- scope note -->
<div v-if="owEditingOrg" class="border-b border-line py-3 text-xs text-ink-muted">
<Icon name="users" :size="13" class="mr-1 inline" />These are organization-wide settings — they apply to everyone in
<span class="font-semibold text-ink-secondary">{{ organizationName || 'your organization' }}</span>. Leave the API key blank to let each user configure their own; a key set here overrides the user's.
</div>
<div v-else-if="owReadOnly" class="border-b border-line py-3 text-xs text-ink-muted">
As a superadmin you manage the global OpenWeather configuration in the API Server panel. The effective configuration is shown below.
</div>
<!-- API key -->
<Row title="API key" desc="Your OpenWeather API key (the appid parameter). Required — OpenWeather has no anonymous tier." keywords="api key appid secret credentials token openweather">
<template v-if="owLocked('apiKey')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ owField('apiKey').effective || '—' }}
<span v-if="owSourceLabel('apiKey')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('apiKey') }}</span>
</span>
</template>
<input v-else v-model="owForm.apiKey" type="password" class="field w-64" placeholder="••••••••" />
</Row>
<!-- units -->
<Row title="Units" desc="Measurement system for temperatures and wind speed." keywords="units metric imperial standard celsius fahrenheit kelvin">
<template v-if="owLocked('units')">
<span class="inline-flex items-center gap-2 text-sm text-ink">
{{ (OW_UNITS_OPTS.find((o) => o.value === owField('units').effective) || {}).label || owField('units').effective || '—' }}
<span v-if="owSourceLabel('units')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('units') }}</span>
</span>
</template>
<Segmented v-else v-model="owForm.units" :options="OW_UNITS_OPTS" />
</Row>
<!-- default latitude -->
<Row title="Default latitude" desc="Latitude used by the health probe and calls with no location (−90…90)." keywords="latitude location coordinates default">
<template v-if="owLocked('lat')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ owField('lat').effective || '—' }}
<span v-if="owSourceLabel('lat')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('lat') }}</span>
</span>
</template>
<input v-else v-model="owForm.lat" inputmode="decimal" class="field w-40 font-mono" placeholder="52.2297" />
</Row>
<!-- default longitude -->
<Row title="Default longitude" desc="Longitude used by the health probe and calls with no location (−180…180)." keywords="longitude location coordinates default">
<template v-if="owLocked('lon')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ owField('lon').effective || '—' }}
<span v-if="owSourceLabel('lon')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('lon') }}</span>
</span>
</template>
<input v-else v-model="owForm.lon" inputmode="decimal" class="field w-40 font-mono" placeholder="21.0122" />
</Row>
<!-- language (cascades independently) -->
<Row title="Language" desc="Optional ISO code for human-readable weather descriptions, e.g. en, pl, de." keywords="language locale description">
<template v-if="owLocked('lang')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ owField('lang').effective || '—' }}
<span v-if="owSourceLabel('lang')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('lang') }}</span>
</span>
</template>
<input v-else v-model="owForm.lang" class="field w-24 font-mono" placeholder="en" />
</Row>
<!-- calls-per-minute limit (used only to gauge usage) -->
<Row title="Calls per minute limit" desc="Your plan's per-minute limit (free tier is 60). Only used to gauge app usage below. Leave blank to inherit; the app falls back to 60." keywords="calls per minute limit rate quota plan usage">
<template v-if="owLocked('callsPerMinute')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ owField('callsPerMinute').effective || '60' }}
<span v-if="owSourceLabel('callsPerMinute')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('callsPerMinute') }}</span>
</span>
</template>
<input v-else v-model="owForm.callsPerMinute" inputmode="numeric" class="field w-24 font-mono" placeholder="60" />
</Row>
<!-- app-side call usage gauge (OpenWeather does not report remaining quota) -->
<div v-if="ow.available && !owEditingOrg" class="my-4 rounded-lg border border-line bg-surface-2 p-4" data-keywords="usage quota rate limit calls per minute remaining left api">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm font-semibold text-ink">
<Icon name="signal" :size="15" />API call usage
</div>
<span v-if="owHealthTs" class="text-[11px] text-ink-muted">Checked {{ owHealthAgo() }}</span>
</div>
<template v-if="owUsage">
<div class="mt-2 flex items-baseline gap-1.5">
<span class="font-mono text-2xl font-semibold text-ink">{{ owUsage.minuteUsed }}</span>
<span class="text-sm text-ink-muted">/ {{ owUsage.minuteLimit || '—' }} calls this minute</span>
</div>
<div v-if="owUsagePct != null" class="mt-2 h-2 w-full overflow-hidden rounded-full bg-line">
<div class="h-full rounded-full transition-all" :class="owUsageBarClass" :style="{ width: owUsagePct + '%' }"></div>
</div>
<div class="mt-2 text-xs text-ink-muted">
{{ owUsage.dayUsed }} calls today · counts only requests PilotVault makes with this key, since server start. OpenWeather does not report remaining quota — check your account dashboard for the authoritative total.
</div>
</template>
<div v-else class="mt-2 text-xs text-ink-muted">
Run <span class="font-semibold text-ink-secondary">Test connection</span> below to record and show call usage.
</div>
</div>
<!-- actions -->
<div class="mt-4 flex flex-wrap items-center gap-3">
<button v-if="!owReadOnly" class="btn-accent" :disabled="owSaving || !ow.available" @click="saveOpenWeatherSettings">
{{ owSaving ? 'Saving…' : owEditingOrg ? 'Save organization settings' : 'Save settings' }}
</button>
<button v-if="!owEditingOrg" class="btn-ghost" :disabled="owTesting || !ow.available" @click="testOpenWeatherConnection">
{{ owTesting ? 'Testing…' : 'Test connection' }}
</button>
<span v-if="owMsg" class="text-xs text-danger-fg">{{ owMsg }}</span>
<span v-if="owHealthTs && !owEditingOrg" class="text-[11px] text-ink-muted">Checked {{ owHealthAgo() }}</span>
<span
v-if="owHealth && !owEditingOrg"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="owHealthClass(owHealth.status)"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ owHealth.detail || owHealth.status }}
</span>
</div>
</div>
</template>
<!-- ==== Drives — External ==== -->
<template v-if="integrationTabShown('drives-external')">
<!-- ---- File transfer (FTP / SFTP) ---- -->
<div class="panel mb-5 p-5">
<!-- header -->
<div class="mb-4 flex items-start gap-3 border-b border-line pb-4">
<div class="grid h-10 w-10 place-items-center rounded-lg bg-accent-soft text-accent-soft-fg">
<Icon name="server" :size="20" />
</div>
<div class="min-w-0">
<div class="text-sm font-semibold text-ink">File Transfer (FTP / SFTP)</div>
<div class="mt-0.5 text-xs text-ink-muted">
Connect to an FTP, FTPS or SFTP server. Configure the connection your account uses for file transfers.
</div>
</div>
</div>
<!-- unavailable notice -->
<div v-if="ft.loaded && !ft.available" class="mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-xs text-ink-secondary">
<Icon name="lock" :size="14" class="mr-1 inline" />
File transfer is currently disabled by your administrator. Contact them to enable it.
</div>
<!-- scope switch (admins manage personal + organization-wide settings) -->
<div v-if="ft.canEditOrg" class="mb-4 flex flex-col gap-2 border-b border-line pb-4 sm:flex-row sm:items-center sm:justify-between">
<div class="text-xs text-ink-muted">Manage your own settings, or organization-wide settings that apply to every user.</div>
<Segmented v-model="ftScope" :options="OS_SCOPE_OPTS" />
</div>
<!-- enable toggle: org master switch (Organization) or personal (My settings) -->
<Row v-if="ftEditingOrg" title="Enable file transfer (organization-wide)" desc="Turn it on or off for everyone in your organization." keywords="enable disable plugin ftp sftp organization">
<Toggle :model-value="ft.orgEnabled" :disabled="!ft.available" @update:model-value="toggleFileTransfer" />
</Row>
<Row v-else title="Enable file transfer" desc="Turn the plugin on for your account in the Web App." keywords="enable disable plugin ftp sftp">
<Toggle :model-value="ft.enabled" :disabled="!ft.available || !ft.orgEnabled" @update:model-value="toggleFileTransfer" />
</Row>
<!-- organization-disabled notice (personal scope) -->
<div v-if="!ftEditingOrg && ft.available && !ft.orgEnabled" class="border-b border-line py-3 text-xs text-amber-fg">
<Icon name="lock" :size="13" class="mr-1 inline" />File transfer is turned off for your organization<span v-if="ft.canEditOrg"> — switch to <span class="font-semibold">Organization</span> to turn it back on</span>.
</div>
<!-- scope note -->
<div v-if="ftEditingOrg" class="border-b border-line py-3 text-xs text-ink-muted">
<Icon name="users" :size="13" class="mr-1 inline" />These are organization-wide settings — they apply to everyone in
<span class="font-semibold text-ink-secondary">{{ organizationName || 'your organization' }}</span>. Leave the connection blank to let each user configure their own; a connection set here overrides the user's.
</div>
<div v-else-if="ftReadOnly" class="border-b border-line py-3 text-xs text-ink-muted">
As a superadmin you manage the global file-transfer configuration in the API Server panel. The effective configuration is shown below.
</div>
<!-- protocol -->
<Row title="Protocol" desc="SFTP (over SSH), FTPS (FTP over TLS), or plain FTP." keywords="protocol sftp ftps ftp">
<template v-if="ftLocked('protocol')">
<span class="inline-flex items-center gap-2 text-sm text-ink">
{{ ftProtoLabel(ftField('protocol').effective) }}
<span v-if="ftSourceLabel('protocol')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ ftSourceLabel('protocol') }}</span>
</span>
</template>
<Segmented v-else v-model="ftForm.protocol" :options="FT_PROTO_OPTS" />
</Row>
<!-- host -->
<Row title="Host" desc="Server hostname or IP address." keywords="host server address">
<template v-if="ftLocked('host')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ ftField('host').effective || '—' }}
<span v-if="ftSourceLabel('host')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ ftSourceLabel('host') }}</span>
</span>
</template>
<input v-else v-model="ftForm.host" class="field w-64" placeholder="files.example.com" />
</Row>
<!-- port -->
<Row title="Port" desc="Leave blank for the protocol default (22 for SFTP, 21 for FTP/FTPS)." keywords="port">
<template v-if="ftLocked('port')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ ftField('port').effective || 'default' }}
<span v-if="ftSourceLabel('port')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ ftSourceLabel('port') }}</span>
</span>
</template>
<input v-else v-model="ftForm.port" inputmode="numeric" class="field w-24 font-mono" placeholder="22" />
</Row>
<!-- username -->
<Row title="Username" desc="Account used to authenticate." keywords="username login account">
<template v-if="ftLocked('username')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ ftField('username').effective || '—' }}
<span v-if="ftSourceLabel('username')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ ftSourceLabel('username') }}</span>
</span>
</template>
<input v-else v-model="ftForm.username" class="field w-64" placeholder="user" />
</Row>
<!-- password -->
<Row title="Password" desc="Password auth for FTP/FTPS, or SFTP password login. Leave blank to use a key." keywords="password secret credentials">
<template v-if="ftLocked('password')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ ftField('password').effective || '—' }}
<span v-if="ftSourceLabel('password')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ ftSourceLabel('password') }}</span>
</span>
</template>
<input v-else v-model="ftForm.password" type="password" class="field w-64" placeholder="••••••••" />
</Row>
<!-- SFTP: private key -->
<Row v-if="ftProto === 'sftp'" block title="SSH private key" desc="PEM key for SFTP key auth — used instead of, or alongside, a password." keywords="private key ssh pem identity">
<template v-if="ftLocked('privateKey')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ ftField('privateKey').effective || '—' }}
<span v-if="ftSourceLabel('privateKey')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ ftSourceLabel('privateKey') }}</span>
</span>
</template>
<textarea v-else v-model="ftForm.privateKey" rows="3" class="field w-full font-mono text-xs" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
</Row>
<!-- SFTP: private key passphrase -->
<Row v-if="ftProto === 'sftp'" title="Private key passphrase" desc="Passphrase protecting the SSH private key, if any." keywords="passphrase key secret">
<template v-if="ftLocked('keyPassphrase')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ ftField('keyPassphrase').effective || '—' }}
<span v-if="ftSourceLabel('keyPassphrase')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ ftSourceLabel('keyPassphrase') }}</span>
</span>
</template>
<input v-else v-model="ftForm.keyPassphrase" type="password" class="field w-64" placeholder="••••••••" />
</Row>
<!-- SFTP: host key fingerprint -->
<Row v-if="ftProto === 'sftp'" title="Host key fingerprint" desc="Optional SHA256:… fingerprint to pin the server's host key. Blank accepts any key." keywords="host key fingerprint verify trust">
<template v-if="ftLocked('hostKeyFingerprint')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ ftField('hostKeyFingerprint').effective || '—' }}
<span v-if="ftSourceLabel('hostKeyFingerprint')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ ftSourceLabel('hostKeyFingerprint') }}</span>
</span>
</template>
<input v-else v-model="ftForm.hostKeyFingerprint" class="field w-full font-mono text-xs" placeholder="SHA256:…" />
</Row>
<!-- FTPS: TLS verification -->
<Row v-if="ftProto === 'ftps'" title="TLS verification" desc="Skip only for self-signed test servers." keywords="tls certificate verify insecure ftps">
<template v-if="ftLocked('insecureSkipVerify')">
<span class="inline-flex items-center gap-2 text-sm text-ink">
{{ ftField('insecureSkipVerify').effective === 'true' ? 'Skip verification' : 'Verify certificate' }}
<span v-if="ftSourceLabel('insecureSkipVerify')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ ftSourceLabel('insecureSkipVerify') }}</span>
</span>
</template>
<Segmented v-else v-model="ftForm.insecureSkipVerify" :options="FT_TLS_OPTS" />
</Row>
<!-- base path (cascades independently) -->
<Row title="Base path" desc="Working directory and health-check target, e.g. /uploads." keywords="base path directory folder root">
<template v-if="ftLocked('basePath')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ ftField('basePath').effective || '—' }}
<span v-if="ftSourceLabel('basePath')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ ftSourceLabel('basePath') }}</span>
</span>
</template>
<input v-else v-model="ftForm.basePath" class="field w-64 font-mono" placeholder="/uploads" />
</Row>
<!-- actions -->
<div class="mt-4 flex flex-wrap items-center gap-3">
<button v-if="!ftReadOnly" class="btn-accent" :disabled="ftSaving || !ft.available" @click="saveFileTransferSettings">
{{ ftSaving ? 'Saving…' : ftEditingOrg ? 'Save organization settings' : 'Save settings' }}
</button>
<button v-if="!ftEditingOrg" class="btn-ghost" :disabled="ftTesting || !ft.available" @click="testFileTransferConnection">
{{ ftTesting ? 'Testing…' : 'Test connection' }}
</button>
<span v-if="ftMsg" class="text-xs text-danger-fg">{{ ftMsg }}</span>
<span v-if="ftHealthTs && !ftEditingOrg" class="text-[11px] text-ink-muted">Checked {{ ftHealthAgo() }}</span>
<span
v-if="ftHealth && !ftEditingOrg"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="ftHealthClass(ftHealth.status)"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ ftHealth.detail || ftHealth.status }}
</span>
</div>
</div>
<!-- ---- WebDAV ---- -->
<div class="panel mb-5 p-5">
<!-- header -->
<div class="mb-4 flex items-start gap-3 border-b border-line pb-4">
<div class="grid h-10 w-10 place-items-center rounded-lg bg-accent-soft text-accent-soft-fg">
<Icon name="cloud" :size="20" />
</div>
<div class="min-w-0">
<div class="text-sm font-semibold text-ink">WebDAV</div>
<div class="mt-0.5 text-xs text-ink-muted">
Connect to a WebDAV server (Nextcloud, ownCloud, IIS, …). Configure the connection your account uses.
</div>
</div>
</div>
<!-- unavailable notice -->
<div v-if="wd.loaded && !wd.available" class="mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-xs text-ink-secondary">
<Icon name="lock" :size="14" class="mr-1 inline" />
WebDAV is currently disabled by your administrator. Contact them to enable it.
</div>
<!-- scope switch (admins manage personal + organization-wide settings) -->
<div v-if="wd.canEditOrg" class="mb-4 flex flex-col gap-2 border-b border-line pb-4 sm:flex-row sm:items-center sm:justify-between">
<div class="text-xs text-ink-muted">Manage your own settings, or organization-wide settings that apply to every user.</div>
<Segmented v-model="wdScope" :options="OS_SCOPE_OPTS" />
</div>
<!-- enable toggle: org master switch (Organization) or personal (My settings) -->
<Row v-if="wdEditingOrg" title="Enable WebDAV (organization-wide)" desc="Turn it on or off for everyone in your organization." keywords="enable disable plugin webdav organization">
<Toggle :model-value="wd.orgEnabled" :disabled="!wd.available" @update:model-value="toggleWebDav" />
</Row>
<Row v-else title="Enable WebDAV" desc="Turn the plugin on for your account in the Web App." keywords="enable disable plugin webdav">
<Toggle :model-value="wd.enabled" :disabled="!wd.available || !wd.orgEnabled" @update:model-value="toggleWebDav" />
</Row>
<!-- organization-disabled notice (personal scope) -->
<div v-if="!wdEditingOrg && wd.available && !wd.orgEnabled" class="border-b border-line py-3 text-xs text-amber-fg">
<Icon name="lock" :size="13" class="mr-1 inline" />WebDAV is turned off for your organization<span v-if="wd.canEditOrg"> — switch to <span class="font-semibold">Organization</span> to turn it back on</span>.
</div>
<!-- scope note -->
<div v-if="wdEditingOrg" class="border-b border-line py-3 text-xs text-ink-muted">
<Icon name="users" :size="13" class="mr-1 inline" />These are organization-wide settings — they apply to everyone in
<span class="font-semibold text-ink-secondary">{{ organizationName || 'your organization' }}</span>. Leave the connection blank to let each user configure their own; a connection set here overrides the user's.
</div>
<div v-else-if="wdReadOnly" class="border-b border-line py-3 text-xs text-ink-muted">
As a superadmin you manage the global WebDAV configuration in the API Server panel. The effective configuration is shown below.
</div>
<!-- server URL -->
<Row block title="Server URL" desc="WebDAV endpoint including scheme, e.g. https://cloud.example.com/remote.php/dav/files/alice/." keywords="url server address endpoint webdav host">
<template v-if="wdLocked('baseURL')">
<span class="inline-flex items-center gap-2 break-all font-mono text-sm text-ink">
{{ wdField('baseURL').effective || '—' }}
<span v-if="wdSourceLabel('baseURL')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ wdSourceLabel('baseURL') }}</span>
</span>
</template>
<input v-else v-model="wdForm.baseURL" class="field w-full font-mono text-xs" placeholder="https://cloud.example.com/remote.php/dav/files/alice/" />
</Row>
<!-- username -->
<Row title="Username" desc="Account used to authenticate (leave blank for a public share)." keywords="username login account">
<template v-if="wdLocked('username')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ wdField('username').effective || '—' }}
<span v-if="wdSourceLabel('username')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ wdSourceLabel('username') }}</span>
</span>
</template>
<input v-else v-model="wdForm.username" class="field w-64" placeholder="user" />
</Row>
<!-- password -->
<Row title="Password" desc="Password or app-specific token for HTTP Basic auth." keywords="password secret credentials token">
<template v-if="wdLocked('password')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ wdField('password').effective || '—' }}
<span v-if="wdSourceLabel('password')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ wdSourceLabel('password') }}</span>
</span>
</template>
<input v-else v-model="wdForm.password" type="password" class="field w-64" placeholder="••••••••" />
</Row>
<!-- TLS verification -->
<Row title="TLS verification" desc="Only affects HTTPS. Skip only for self-signed test servers." keywords="tls certificate verify insecure https">
<template v-if="wdLocked('insecureSkipVerify')">
<span class="inline-flex items-center gap-2 text-sm text-ink">
{{ wdField('insecureSkipVerify').effective === 'true' ? 'Skip verification' : 'Verify certificate' }}
<span v-if="wdSourceLabel('insecureSkipVerify')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ wdSourceLabel('insecureSkipVerify') }}</span>
</span>
</template>
<Segmented v-else v-model="wdForm.insecureSkipVerify" :options="WD_TLS_OPTS" />
</Row>
<!-- base path (cascades independently) -->
<Row title="Base path" desc="Working directory under the server URL and health-check target, e.g. /Documents." keywords="base path directory folder root">
<template v-if="wdLocked('basePath')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ wdField('basePath').effective || '—' }}
<span v-if="wdSourceLabel('basePath')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ wdSourceLabel('basePath') }}</span>
</span>
</template>
<input v-else v-model="wdForm.basePath" class="field w-64 font-mono" placeholder="/Documents" />
</Row>
<!-- actions -->
<div class="mt-4 flex flex-wrap items-center gap-3">
<button v-if="!wdReadOnly" class="btn-accent" :disabled="wdSaving || !wd.available" @click="saveWebDavSettings">
{{ wdSaving ? 'Saving…' : wdEditingOrg ? 'Save organization settings' : 'Save settings' }}
</button>
<button v-if="!wdEditingOrg" class="btn-ghost" :disabled="wdTesting || !wd.available" @click="testWebDavConnection">
{{ wdTesting ? 'Testing…' : 'Test connection' }}
</button>
<span v-if="wdMsg" class="text-xs text-danger-fg">{{ wdMsg }}</span>
<span v-if="wdHealthTs && !wdEditingOrg" class="text-[11px] text-ink-muted">Checked {{ wdHealthAgo() }}</span>
<span
v-if="wdHealth && !wdEditingOrg"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="wdHealthClass(wdHealth.status)"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ wdHealth.detail || wdHealth.status }}
</span>
</div>
</div>
</template>
<!-- ==== Drives — Local ==== -->
<template v-if="integrationTabShown('drives-local')">
<!-- ---- Local Storage (host filesystem) ---- -->
<div class="panel mb-5 p-5">
<!-- header -->
<div class="mb-4 flex items-start gap-3 border-b border-line pb-4">
<div class="grid h-10 w-10 place-items-center rounded-lg bg-accent-soft text-accent-soft-fg">
<Icon name="monitor" :size="20" />
</div>
<div class="min-w-0">
<div class="text-sm font-semibold text-ink">Local Storage</div>
<div class="mt-0.5 text-xs text-ink-muted">
A private folder on the server for your files. Each user has their own; members of an organization share one.
</div>
</div>
</div>
<!-- unavailable notice -->
<div v-if="ls.loaded && !ls.available" class="mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-xs text-ink-secondary">
<Icon name="lock" :size="14" class="mr-1 inline" />
Local storage is currently disabled by your administrator. Contact them to enable it.
</div>
<div v-else-if="ls.loaded && !ls.rootConfigured" class="mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-xs text-ink-secondary">
<Icon name="alertTriangle" :size="14" class="mr-1 inline" />
No storage root has been configured by your administrator yet.
</div>
<!-- scope switch (admins manage personal + organization-wide settings) -->
<div v-if="ls.canEditOrg" class="mb-4 flex flex-col gap-2 border-b border-line pb-4 sm:flex-row sm:items-center sm:justify-between">
<div class="text-xs text-ink-muted">Manage your own settings, or organization-wide settings that apply to every user.</div>
<Segmented v-model="lsScope" :options="OS_SCOPE_OPTS" />
</div>
<!-- enable toggle: org master switch (Organization) or personal (My settings) -->
<Row v-if="lsEditingOrg" title="Enable local storage (organization-wide)" desc="Turn it on or off for everyone in your organization." keywords="enable disable plugin local storage folder organization">
<Toggle :model-value="ls.orgEnabled" :disabled="!ls.available" @update:model-value="toggleLocalStorage" />
</Row>
<Row v-else title="Enable local storage" desc="Turn the plugin on for your account in the Web App." keywords="enable disable plugin local storage folder">
<Toggle :model-value="ls.enabled" :disabled="!ls.available || !ls.orgEnabled" @update:model-value="toggleLocalStorage" />
</Row>
<!-- organization-disabled notice (personal scope) -->
<div v-if="!lsEditingOrg && ls.available && !ls.orgEnabled" class="border-b border-line py-3 text-xs text-amber-fg">
<Icon name="lock" :size="13" class="mr-1 inline" />Local storage is turned off for your organization<span v-if="ls.canEditOrg"> — switch to <span class="font-semibold">Organization</span> to turn it back on</span>.
</div>
<!-- scope note -->
<div v-if="lsEditingOrg" class="border-b border-line py-3 text-xs text-ink-muted">
<Icon name="users" :size="13" class="mr-1 inline" />These are organization-wide settings — they apply to everyone in
<span class="font-semibold text-ink-secondary">{{ organizationName || 'your organization' }}</span>, who all share the organization folder. Members can additionally enable a private folder inside it.
</div>
<div v-else-if="lsReadOnly" class="border-b border-line py-3 text-xs text-ink-muted">
As a superadmin you manage the global storage root in the API Server panel. The effective configuration is shown below.
</div>
<!-- org policy: allow private folders (organization scope) -->
<Row v-if="lsEditingOrg" title="Private folders" desc="Let members create a private folder inside the organization folder, reachable only by them." keywords="private folder members allow policy organization">
<Toggle :model-value="ls.allowPrivate" :disabled="!ls.available" @update:model-value="toggleAllowPrivate" />
</Row>
<!-- personal scope: folders + private opt-in -->
<template v-if="!lsEditingOrg">
<!-- isolated folders (read-only; derived from identity) -->
<Row block title="Your folders" desc="Assigned automatically and isolated — no one else can reach your private folder." keywords="folder directory path storage location isolated private shared">
<div class="flex w-full flex-col gap-2">
<div v-for="m in ls.mounts" :key="m.id" class="flex flex-wrap items-center gap-2">
<span class="break-all font-mono text-sm text-ink">{{ m.path }}</span>
<span v-if="m.kind === 'shared'" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="users" :size="10" />Shared with your organization</span>
<span v-else class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />Private to you</span>
<span
v-if="lsMountHealth[m.id]"
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="lsHealthClass(lsMountHealth[m.id].status)"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ lsMountHealth[m.id].status }}
</span>
</div>
<div v-if="!ls.mounts.length" class="text-xs text-ink-muted">
{{ ls.rootConfigured ? 'No folder assigned yet.' : 'Waiting for the administrator to configure a storage root.' }}
</div>
</div>
</Row>
<!-- private-folder opt-in (organization members only) -->
<Row v-if="ls.isOrgUser && ls.allowPrivate" title="My private folder" desc="Add a private folder inside the organization folder, reachable only by you — you keep the shared folder too." keywords="private folder personal isolated organization inside">
<Toggle :model-value="ls.privateFolder" :disabled="!ls.available || !ls.orgEnabled" @update:model-value="togglePrivateFolder" />
</Row>
<div v-else-if="ls.isOrgUser && !ls.allowPrivate" class="border-b border-line py-3 text-xs text-ink-muted">
<Icon name="lock" :size="13" class="mr-1 inline" />Private folders are turned off by your organization.
</div>
</template>
<!-- access mode (readOnly cascades) -->
<Row title="Access mode" desc="Read-only prevents uploads, deletes and folder creation." keywords="read only write access mode permission">
<template v-if="lsLocked('readOnly')">
<span class="inline-flex items-center gap-2 text-sm text-ink">
{{ lsRoLabel(lsField('readOnly').effective) }}
<span v-if="lsSourceLabel('readOnly')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ lsSourceLabel('readOnly') }}</span>
</span>
</template>
<Segmented v-else v-model="lsReadOnlyForm" :options="LS_RO_OPTS" />
</Row>
<!-- actions -->
<div class="mt-4 flex flex-wrap items-center gap-3">
<button v-if="!lsReadOnly" class="btn-accent" :disabled="lsSaving || !ls.available" @click="saveLocalStorageSettings">
{{ lsSaving ? 'Saving…' : lsEditingOrg ? 'Save organization settings' : 'Save settings' }}
</button>
<button v-if="!lsEditingOrg" class="btn-ghost" :disabled="lsTesting || !ls.available" @click="testLocalStorageConnection">
{{ lsTesting ? 'Testing…' : 'Test folder' }}
</button>
<span v-if="lsMsg" class="text-xs text-danger-fg">{{ lsMsg }}</span>
<span v-if="lsHealthTs && !lsEditingOrg" class="text-[11px] text-ink-muted">Checked {{ lsHealthAgo() }}</span>
<span
v-if="lsHealth && !lsEditingOrg"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="lsHealthClass(lsHealth.status)"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ lsHealth.detail || lsHealth.status }}
</span>
</div>
</div>
</template>
</div>
<!-- ============ PROFILE ============ -->
<div v-else-if="s.id === 'profile'" class="panel mb-5 p-5">
<Row block title="Profile photo" desc="PNG or JPG, up to ~1.5 MB. Stored on this device." keywords="avatar photo picture">
<div class="flex items-center gap-4">
<img v-if="prefs.avatar" :src="prefs.avatar" alt="Avatar" class="h-16 w-16 rounded-full object-cover" />
<div v-else class="grid h-16 w-16 place-items-center rounded-full bg-[var(--navy-800)] text-lg font-bold text-white">
{{ initials }}
</div>
<div class="flex gap-2">
<label class="btn-ghost cursor-pointer">
<Icon name="upload" :size="15" class="mr-1.5 inline" />Upload
<input type="file" accept="image/*" class="hidden" @change="onAvatar" />
</label>
<button v-if="prefs.avatar" class="btn-ghost" @click="removeAvatar">Remove</button>
</div>
</div>
</Row>
<Row title="Display name" desc="The name shown on your public profile." keywords="display name profile">
<input v-model="prefs.displayName" class="field w-56" placeholder="Jane O." @blur="flash('Saved.')" />
</Row>
<Row block title="Bio" desc="A short description others can see." keywords="bio about description">
<textarea
v-model="prefs.bio"
rows="3"
maxlength="240"
class="field w-full resize-none"
placeholder="Flight director, North yard operations…"
@blur="flash('Saved.')"
></textarea>
<div class="mt-1 text-right text-[11px] text-ink-muted">{{ (prefs.bio || '').length }}/240</div>
</Row>
<Row title="Show email on profile" desc="Let teammates see your email address." keywords="show email public visibility">
<Toggle v-model="prefs.showEmail" />
</Row>
</div>
<!-- ============ PRIVACY & SECURITY ============ -->
<div v-else-if="s.id === 'security'" class="panel mb-5 p-5">
<Row block title="Two-factor authentication" desc="Require a one-time code at sign-in." keywords="two factor 2fa authentication security">
<div class="flex items-center gap-3">
<span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="prefs.twoFactor ? 'bg-success-soft text-success-fg' : 'bg-surface-2 text-ink-secondary'"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ prefs.twoFactor ? 'Enabled' : 'Disabled' }}
</span>
<button v-if="!prefs.twoFactor && !twoFASetup" class="btn-accent" @click="beginTwoFA">Enable 2FA</button>
<button v-else-if="prefs.twoFactor" class="btn-ghost" @click="disableTwoFA">Disable</button>
</div>
<!-- setup flow -->
<div v-if="twoFASetup" class="mt-4 rounded-lg border border-line bg-surface-2 p-4">
<div class="flex flex-wrap items-center gap-4">
<div class="grid h-28 w-28 place-items-center rounded bg-white p-2">
<!-- placeholder QR block -->
<svg viewBox="0 0 100 100" class="h-full w-full">
<rect width="100" height="100" fill="#fff" />
<g fill="#0F1E3D">
<rect x="6" y="6" width="24" height="24" /><rect x="70" y="6" width="24" height="24" />
<rect x="6" y="70" width="24" height="24" /><rect x="12" y="12" width="12" height="12" fill="#fff" />
<rect x="76" y="12" width="12" height="12" fill="#fff" /><rect x="12" y="76" width="12" height="12" fill="#fff" />
<rect x="40" y="10" width="8" height="8" /><rect x="52" y="20" width="8" height="8" />
<rect x="40" y="40" width="8" height="8" /><rect x="60" y="44" width="8" height="8" />
<rect x="44" y="60" width="8" height="8" /><rect x="70" y="60" width="8" height="8" />
<rect x="80" y="72" width="8" height="8" /><rect x="60" y="80" width="8" height="8" />
</g>
</svg>
</div>
<div class="min-w-0">
<div class="text-xs text-ink-secondary">Scan with an authenticator app, or enter this secret:</div>
<div class="mt-1 select-all font-mono text-sm font-bold text-ink">{{ twoFASecret }}</div>
<div class="mt-3 flex items-center gap-2">
<input
v-model="twoFACode"
inputmode="numeric"
maxlength="6"
class="field w-28 font-mono tracking-[0.3em]"
placeholder="000000"
/>
<button class="btn-accent" @click="confirmTwoFA">Verify &amp; enable</button>
</div>
<p v-if="twoFAMsg" class="mt-2 text-xs text-danger-fg">{{ twoFAMsg }}</p>
</div>
</div>
</div>
<!-- recovery codes after enabling -->
<div v-if="prefs.twoFactor && recoveryCodes.length" class="mt-4 rounded-lg border border-line bg-surface-2 p-4">
<div class="text-xs font-semibold text-ink">Recovery codes</div>
<div class="mt-0.5 text-xs text-ink-muted">Store these somewhere safe — each works once.</div>
<div class="mt-2 grid grid-cols-2 gap-1 font-mono text-xs text-ink-secondary sm:grid-cols-4">
<span v-for="c in recoveryCodes" :key="c" class="select-all">{{ c }}</span>
</div>
</div>
<p class="mt-2 text-xs text-ink-muted">Prototype — codes are generated locally until the account service verifies them.</p>
</Row>
<Row block title="Active sessions" desc="Devices currently signed in to your account." keywords="sessions devices logout sign out remote">
<div class="rounded-lg border border-line bg-surface-2 p-3">
<div class="flex items-center gap-3">
<div class="grid h-9 w-9 place-items-center rounded-full bg-accent-soft text-accent-soft-fg">
<Icon name="monitor" :size="18" />
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">
{{ browserName() }} on {{ osName() }}
<span class="ml-1 rounded-full bg-success-soft px-2 py-0.5 text-[10px] font-semibold text-success-fg">This device</span>
</div>
<div class="font-mono text-[11px] text-ink-muted">Signed in {{ formatDateTime(sessionStart) }}</div>
</div>
<button class="btn-ghost" @click="emit('logout')">Log out</button>
</div>
</div>
<button class="btn-ghost mt-2 opacity-60" disabled title="Requires the account service">
Log out all other devices
</button>
<p class="mt-2 text-xs text-ink-muted">
Only this session is visible from the browser; enumerating and revoking remote sessions needs the account service.
</p>
</Row>
</div>
<!-- ============ USER MANAGEMENT (admin) ============ -->
<div v-else-if="s.id === 'team'" class="mb-5">
<!-- edit user (shown while editing) -->
<div v-if="edit.id" class="panel mb-5 p-5">
<Row block :title="`Edit user — ${edit.email}`" desc="Update details, change role, reset password, or set verified." keywords="edit user update role password verified organization">
<div class="grid max-w-[520px] gap-2">
<div class="flex flex-wrap gap-2">
<input v-model="edit.email" type="email" class="field flex-1" placeholder="name@pilotvault.local" />
<select v-model="edit.role" class="field w-32" :disabled="editingSelf" :title="editingSelf ? 'You cannot change your own role' : ''">
<option v-for="o in ROLE_OPTS" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
<select v-if="isSuperadmin" v-model="edit.organization" class="field" title="Organization">
<option v-for="o in ORG_OPTS" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<input v-model="edit.password" type="password" class="field" placeholder="New password (leave blank to keep current)" />
<label class="flex items-center gap-2 py-1 text-sm text-ink-secondary">
<Toggle v-model="edit.verified" /> Email verified
</label>
<div class="flex items-center gap-3">
<button class="btn-accent" :disabled="savingEdit" @click="saveEdit">
{{ savingEdit ? 'Saving…' : 'Save changes' }}
</button>
<button class="btn-ghost" @click="cancelEdit">Cancel</button>
<span v-if="editMsg" class="text-xs text-danger-fg">{{ editMsg }}</span>
<span v-if="editingSelf" class="text-xs text-ink-muted">Editing your own account — role locked.</span>
</div>
</div>
</Row>
</div>
<!-- add user -->
<div v-else class="panel mb-5 p-5">
<Row block title="Add user" desc="Create a new account. Admins add users within their organization; superadmins can target any." keywords="add user create account role admin organization">
<div class="grid max-w-[520px] gap-2">
<div class="flex flex-wrap gap-2">
<input v-model="nu.email" type="email" class="field flex-1" placeholder="name@pilotvault.local" />
<select v-model="nu.role" class="field w-32">
<option v-for="o in ROLE_OPTS" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
<select v-if="isSuperadmin" v-model="nu.organization" class="field" title="Organization">
<option v-for="o in ORG_OPTS" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<div v-else class="text-xs text-ink-muted">
New users join your organization: <span class="font-semibold text-ink-secondary">{{ organizationName || '—' }}</span>
</div>
<input v-model="nu.password" type="password" class="field" placeholder="Temporary password (min 8 chars)" />
<div class="flex items-center gap-3">
<button class="btn-accent" :disabled="creating" @click="addUser">
{{ creating ? 'Creating…' : 'Create user' }}
</button>
<span v-if="nuMsg" class="text-xs text-danger-fg">{{ nuMsg }}</span>
</div>
</div>
</Row>
</div>
<!-- user list -->
<div class="panel overflow-hidden p-0">
<div class="flex items-center justify-between px-5 py-4">
<div>
<div class="eyebrow">Team</div>
<div class="mt-0.5 text-base font-semibold text-ink">All users</div>
</div>
<button class="btn-ghost" :disabled="usersLoading" @click="loadUsers">
{{ usersLoading ? 'Loading…' : 'Refresh' }}
</button>
</div>
<div v-if="usersErr" class="px-5 pb-5 text-sm text-danger-fg">{{ usersErr }}</div>
<div v-else-if="!users.length && !usersLoading" class="px-5 pb-8 text-sm text-ink-muted">No users yet.</div>
<div v-else class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="text-left">
<th
v-for="h in ['User', 'Role', 'Organization', 'Status', '']"
:key="h"
class="border-y border-line bg-surface-2 px-5 py-2.5 font-mono text-[10.5px] font-normal uppercase tracking-caps text-ink-muted"
>
{{ h }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="u in users"
:key="u.id"
class="border-b border-line last:border-0"
:class="edit.id === u.id ? 'bg-accent-soft' : ''"
>
<td class="px-5 py-3">
<span class="text-ink">{{ u.email }}</span>
<span v-if="u.email === email" class="ml-1.5 text-[11px] text-ink-muted">(you)</span>
</td>
<td class="px-5 py-3">
<span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="roleBadge(u.role || 'user')"
>
<Icon :name="roleIcon(u.role || 'user')" :size="12" />{{ roleLabel(u.role || 'user') }}
</span>
</td>
<td class="px-5 py-3">
<span class="text-sm" :class="u.organizationName ? 'text-ink-secondary' : 'text-ink-muted'">
{{ u.organizationName || '—' }}
</span>
</td>
<td class="px-5 py-3">
<span class="text-xs" :class="u.verified ? 'text-success-fg' : 'text-ink-muted'">
{{ u.verified ? 'Verified' : 'Unverified' }}
</span>
</td>
<td class="px-5 py-3 text-right">
<template v-if="confirmId === u.id">
<span class="mr-2 text-xs text-ink-muted">Remove?</span>
<button class="btn-ghost mr-1" @click="confirmId = ''">Cancel</button>
<button
class="rounded bg-danger px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110"
@click="removeUser(u)"
>
Remove
</button>
</template>
<div v-else class="inline-flex items-center gap-1.5">
<button class="btn-ghost inline-flex items-center gap-1.5" @click="startEdit(u)">
<Icon name="settings" :size="14" /> Edit
</button>
<button
v-if="u.email !== email"
class="btn-ghost inline-flex items-center gap-1.5"
@click="confirmId = u.id"
>
<Icon name="trash" :size="14" /> Remove
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ============ ORGANIZATIONS (superadmin) ============ -->
<div v-else-if="s.id === 'organizations'" class="mb-5">
<!-- rename org (shown while editing) -->
<div v-if="orgEdit.id" class="panel mb-5 p-5">
<Row block :title="`Rename organization`" desc="Update the organization's display name." keywords="rename organization edit">
<div class="grid max-w-[520px] gap-2">
<input v-model="orgEdit.name" class="field" placeholder="Organization name" @keyup.enter="saveOrg" />
<div class="flex items-center gap-3">
<button class="btn-accent" @click="saveOrg">Save changes</button>
<button class="btn-ghost" @click="cancelOrgEdit">Cancel</button>
<span v-if="orgEditMsg" class="text-xs text-danger-fg">{{ orgEditMsg }}</span>
</div>
</div>
</Row>
</div>
<!-- add org -->
<div v-else class="panel mb-5 p-5">
<Row block title="Add organization" desc="Create a new organization. Assign admins and users to it from User management." keywords="add organization create tenant company">
<div class="grid max-w-[520px] gap-2">
<input v-model="no.name" class="field" placeholder="e.g. Northwind Aerial" @keyup.enter="addOrg" />
<div class="flex items-center gap-3">
<button class="btn-accent" :disabled="orgBusy" @click="addOrg">
{{ orgBusy ? 'Creating…' : 'Create organization' }}
</button>
<span v-if="orgMsg" class="text-xs text-danger-fg">{{ orgMsg }}</span>
</div>
</div>
</Row>
</div>
<!-- org list -->
<div class="panel overflow-hidden p-0">
<div class="flex items-center justify-between px-5 py-4">
<div>
<div class="eyebrow">Tenancy</div>
<div class="mt-0.5 text-base font-semibold text-ink">All organizations</div>
</div>
<button class="btn-ghost" @click="loadOrgs">Refresh</button>
</div>
<div v-if="!orgs.length" class="px-5 pb-8 text-sm text-ink-muted">No organizations yet.</div>
<div v-else class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="text-left">
<th
v-for="h in ['Organization', 'Members', '']"
:key="h"
class="border-y border-line bg-surface-2 px-5 py-2.5 font-mono text-[10.5px] font-normal uppercase tracking-caps text-ink-muted"
>
{{ h }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="o in orgs"
:key="o.id"
class="border-b border-line last:border-0"
:class="orgEdit.id === o.id ? 'bg-accent-soft' : ''"
>
<td class="px-5 py-3">
<span class="inline-flex items-center gap-2 text-ink">
<Icon name="grid" :size="14" class="text-ink-muted" />{{ o.name }}
</span>
</td>
<td class="px-5 py-3 text-ink-secondary">{{ orgMemberCount[o.id] || 0 }}</td>
<td class="px-5 py-3 text-right">
<template v-if="orgConfirmId === o.id">
<span class="mr-2 text-xs text-ink-muted">Delete?</span>
<button class="btn-ghost mr-1" @click="orgConfirmId = ''">Cancel</button>
<button
class="rounded bg-danger px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110"
@click="removeOrg(o)"
>
Delete
</button>
</template>
<div v-else class="inline-flex items-center gap-1.5">
<button class="btn-ghost inline-flex items-center gap-1.5" @click="startOrgEdit(o)">
<Icon name="settings" :size="14" /> Rename
</button>
<button
class="btn-ghost inline-flex items-center gap-1.5"
:disabled="(orgMemberCount[o.id] || 0) > 0"
:title="(orgMemberCount[o.id] || 0) > 0 ? 'Reassign or remove members first' : ''"
@click="orgConfirmId = o.id"
>
<Icon name="trash" :size="14" /> Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ============ ADVANCED / DANGER ZONE ============ -->
<div v-else-if="s.id === 'advanced'" class="mb-5">
<div class="panel mb-5 p-5">
<Row title="Export data" desc="Download your settings and profile as JSON." keywords="export data download backup">
<button class="btn-ghost" @click="exportData"><Icon name="download" :size="15" class="mr-1.5 inline" />Export</button>
</Row>
<Row block title="Import data" desc="Restore settings from a previous export." keywords="import data upload restore">
<label class="btn-ghost cursor-pointer">
<Icon name="upload" :size="15" class="mr-1.5 inline" />Choose file…
<input type="file" accept="application/json,.json" class="hidden" @change="onImport" />
</label>
<p v-if="importMsg" class="mt-2 text-xs text-ink-muted">{{ importMsg }}</p>
</Row>
</div>
<!-- Danger zone — visually isolated -->
<div
class="rounded-lg border p-5"
style="border-color: color-mix(in srgb, var(--danger) 35%, transparent); background: var(--danger-soft)"
>
<div class="flex items-center gap-2 text-danger-fg">
<Icon name="alertTriangle" :size="18" />
<h3 class="text-sm font-bold uppercase tracking-caps">Danger zone</h3>
</div>
<p class="mt-1 text-xs text-ink-secondary">Deleting your account is permanent and cannot be undone.</p>
<div class="mt-4 rounded-lg border border-line bg-surface-1 p-4">
<div class="text-sm font-semibold text-ink">Delete account</div>
<label class="mt-3 flex items-start gap-2 text-sm text-ink-secondary">
<input v-model="del.understand" type="checkbox" class="mt-0.5 h-4 w-4 accent-[var(--danger)]" />
I understand this permanently deletes my account and all associated data.
</label>
<div class="mt-3">
<label class="eyebrow mb-1 block">Type <span class="text-ink">{{ delTarget }}</span> to confirm</label>
<input v-model="del.typed" class="field w-full max-w-[360px] font-mono" :placeholder="delTarget" />
</div>
<div class="mt-4 flex flex-wrap items-center gap-3">
<button
v-if="!del.armed"
class="rounded bg-danger px-4 py-2.5 text-sm font-semibold text-white transition enabled:hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="!delReady"
@click="arm"
>
Delete account…
</button>
<button
v-else
class="rounded bg-danger px-4 py-2.5 text-sm font-semibold text-white transition enabled:hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="del.cooldown > 0"
@click="deleteAccount"
>
{{ del.cooldown > 0 ? `Confirm in ${del.cooldown}s…` : 'Permanently delete account' }}
</button>
<span v-if="del.armed && del.cooldown > 0" class="text-xs text-ink-muted">Cooling-off period — read once more.</span>
</div>
<p v-if="del.msg" class="mt-3 rounded border border-line bg-surface-2 px-3 py-2 text-xs text-ink-secondary">{{ del.msg }}</p>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- transient save flash -->
<transition name="fade">
<div v-if="savedFlash" class="fixed bottom-5 right-5 z-20 flex items-center gap-2 rounded-lg border border-line bg-surface-1 px-4 py-2.5 text-sm text-ink shadow-md">
<Icon name="check" :size="16" class="text-success-fg" />{{ savedFlash }}
</div>
</transition>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>