fix(edge-fn): replace getClaims with adminClient.auth.getUser(token)
fix(edge-fn): use user.id instead of claims.sub; fixes 500s and false cert_required fix(migrations): drop broad reservations SELECT policy; add reservation_slots view with security_invoker=false fix(tests): correct weekSlot() keys from start/end to start_time/end_time fix(tests): spread overlap test slots across separate ISO weeks fix(tests): update e2e assertion to match actual authenticated home text fix(app): hide IonMenu before user is authenticated feat(dx): add test:all script running unit, integration, and e2e in sequence docs(claude-md): document SELinux fix, Edge Function auth pattern, security_invoker behaviour
This commit is contained in:
278
app/pages/admin/config.vue
Normal file
278
app/pages/admin/config.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar color="primary">
|
||||
<IonButtons slot="start">
|
||||
<IonMenuButton />
|
||||
</IonButtons>
|
||||
<IonTitle>Booking Rules</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
<IonButton :disabled="!dirty || saving" @click="saveAll">
|
||||
<IonSpinner v-if="saving" name="crescent" slot="icon-only" />
|
||||
<IonIcon v-else slot="icon-only" :icon="saveOutline" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent class="ion-padding">
|
||||
<div v-if="loading" class="ion-text-center ion-padding">
|
||||
<IonSpinner name="crescent" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<IonCard>
|
||||
<IonCardHeader>
|
||||
<IonCardTitle>Weekly Limits</IonCardTitle>
|
||||
</IonCardHeader>
|
||||
<IonCardContent>
|
||||
<IonList lines="full">
|
||||
<IonItem>
|
||||
<IonLabel>
|
||||
<h3>Max sessions per week</h3>
|
||||
<p>Pre-booked sessions allowed per member per ISO week (Mon–Sun)</p>
|
||||
</IonLabel>
|
||||
<IonInput
|
||||
slot="end"
|
||||
type="number"
|
||||
:value="local.max_sessions_per_week"
|
||||
class="config-input"
|
||||
:min="1"
|
||||
:max="14"
|
||||
@ion-input="set('max_sessions_per_week', $event.detail.value)"
|
||||
/>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
|
||||
<IonCard>
|
||||
<IonCardHeader>
|
||||
<IonCardTitle>Weekend / Holiday Limits</IonCardTitle>
|
||||
</IonCardHeader>
|
||||
<IonCardContent>
|
||||
<IonList lines="full">
|
||||
<IonItem>
|
||||
<IonLabel>
|
||||
<h3>Max weekend sessions per period</h3>
|
||||
<p>Max Sat/Sun/holiday pre-bookings per alternating period</p>
|
||||
</IonLabel>
|
||||
<IonInput
|
||||
slot="end"
|
||||
type="number"
|
||||
:value="local.max_weekend_sessions_per_period"
|
||||
class="config-input"
|
||||
:min="1"
|
||||
:max="10"
|
||||
@ion-input="set('max_weekend_sessions_per_period', $event.detail.value)"
|
||||
/>
|
||||
</IonItem>
|
||||
<IonItem>
|
||||
<IonLabel>
|
||||
<h3>Weekend period length (weeks)</h3>
|
||||
<p>Number of weeks in each alternating period (default: 2 = every other weekend)</p>
|
||||
</IonLabel>
|
||||
<IonInput
|
||||
slot="end"
|
||||
type="number"
|
||||
:value="local.weekend_period_weeks"
|
||||
class="config-input"
|
||||
:min="1"
|
||||
:max="8"
|
||||
@ion-input="set('weekend_period_weeks', $event.detail.value)"
|
||||
/>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
|
||||
<IonCard>
|
||||
<IonCardHeader>
|
||||
<IonCardTitle>Open Session Window</IonCardTitle>
|
||||
</IonCardHeader>
|
||||
<IonCardContent>
|
||||
<IonList lines="full">
|
||||
<IonItem>
|
||||
<IonLabel>
|
||||
<h3>Advance hours for open sessions</h3>
|
||||
<p>Pre-booking limits are waived for sessions starting within this many hours</p>
|
||||
</IonLabel>
|
||||
<IonInput
|
||||
slot="end"
|
||||
type="number"
|
||||
:value="local.open_session_advance_hours"
|
||||
class="config-input"
|
||||
:min="1"
|
||||
:max="72"
|
||||
@ion-input="set('open_session_advance_hours', $event.detail.value)"
|
||||
/>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
|
||||
<IonCard>
|
||||
<IonCardHeader>
|
||||
<IonCardTitle>Holidays</IonCardTitle>
|
||||
<IonCardSubtitle>Counted as weekend days for booking limits</IonCardSubtitle>
|
||||
</IonCardHeader>
|
||||
<IonCardContent>
|
||||
<IonList lines="full">
|
||||
<IonItem v-for="h in holidays" :key="h.date">
|
||||
<IonLabel>
|
||||
<h3>{{ h.name }}</h3>
|
||||
<p>{{ h.date }}</p>
|
||||
</IonLabel>
|
||||
<IonButton slot="end" fill="clear" color="danger" @click="deleteHoliday(h.date)">
|
||||
<IonIcon slot="icon-only" :icon="trashOutline" />
|
||||
</IonButton>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
<div class="add-holiday-row">
|
||||
<IonInput v-model="newHoliday.date" type="date" placeholder="Date" class="holiday-date-input" />
|
||||
<IonInput v-model="newHoliday.name" placeholder="Name (e.g. Canada Day)" class="holiday-name-input" />
|
||||
<IonButton @click="addHoliday" :disabled="!newHoliday.date || !newHoliday.name">
|
||||
<IonIcon slot="icon-only" :icon="addOutline" />
|
||||
</IonButton>
|
||||
</div>
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
</template>
|
||||
|
||||
<IonToast
|
||||
v-model:is-open="toast.show"
|
||||
:message="toast.message"
|
||||
:color="toast.color"
|
||||
:duration="2500"
|
||||
position="bottom"
|
||||
/>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
|
||||
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
|
||||
IonCardSubtitle, IonCardContent, IonList, IonItem, IonLabel, IonInput,
|
||||
IonSpinner, IonToast,
|
||||
} from '@ionic/vue'
|
||||
import { saveOutline, trashOutline, addOutline } from 'ionicons/icons'
|
||||
import type { Database } from '~/types/supabase'
|
||||
|
||||
type ConfigKey = 'max_sessions_per_week' | 'max_weekend_sessions_per_period' | 'weekend_period_weeks' | 'open_session_advance_hours'
|
||||
type Holiday = { date: string; name: string }
|
||||
|
||||
definePageMeta({ layout: false, middleware: ['auth'] })
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supabase = useSupabaseClient() as any
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const dirty = ref(false)
|
||||
const toast = reactive({ show: false, message: '', color: 'success' })
|
||||
const holidays = ref<Holiday[]>([])
|
||||
const newHoliday = reactive({ date: '', name: '' })
|
||||
|
||||
const local = reactive<Record<ConfigKey, number>>({
|
||||
max_sessions_per_week: 2,
|
||||
max_weekend_sessions_per_period: 1,
|
||||
weekend_period_weeks: 2,
|
||||
open_session_advance_hours: 24,
|
||||
})
|
||||
|
||||
const original = reactive<Record<ConfigKey, number>>({ ...local })
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchConfig(), fetchHolidays()])
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
async function fetchConfig() {
|
||||
const { data } = await supabase
|
||||
.from('booking_config')
|
||||
.select('key, value')
|
||||
|
||||
for (const row of (data ?? []) as { key: string; value: unknown }[]) {
|
||||
const k = row.key as ConfigKey
|
||||
if (k in local) {
|
||||
local[k] = Number(row.value)
|
||||
original[k] = Number(row.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHolidays() {
|
||||
const { data } = await supabase
|
||||
.from('holidays')
|
||||
.select('date, name')
|
||||
.order('date', { ascending: true })
|
||||
holidays.value = (data ?? []) as Holiday[]
|
||||
}
|
||||
|
||||
function set(key: ConfigKey, value: string | number | null | undefined) {
|
||||
const n = Number(value)
|
||||
if (!isNaN(n) && n > 0) {
|
||||
local[key] = n
|
||||
dirty.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
saving.value = true
|
||||
const upserts = (Object.keys(local) as ConfigKey[]).map(k => ({ key: k, value: local[k] }))
|
||||
|
||||
const { error } = await supabase
|
||||
.from('booking_config')
|
||||
.upsert(upserts, { onConflict: 'key' })
|
||||
|
||||
saving.value = false
|
||||
if (error) { showToast('Save failed: ' + error.message, 'danger'); return }
|
||||
Object.assign(original, local)
|
||||
dirty.value = false
|
||||
showToast('Booking rules saved', 'success')
|
||||
}
|
||||
|
||||
async function addHoliday() {
|
||||
const { error } = await supabase
|
||||
.from('holidays')
|
||||
.insert({ date: newHoliday.date, name: newHoliday.name })
|
||||
if (error) { showToast('Failed: ' + error.message, 'danger'); return }
|
||||
newHoliday.date = ''
|
||||
newHoliday.name = ''
|
||||
await fetchHolidays()
|
||||
showToast('Holiday added', 'success')
|
||||
}
|
||||
|
||||
async function deleteHoliday(date: string) {
|
||||
const { error } = await supabase
|
||||
.from('holidays')
|
||||
.delete()
|
||||
.eq('date', date)
|
||||
if (error) { showToast('Failed: ' + error.message, 'danger'); return }
|
||||
await fetchHolidays()
|
||||
showToast('Holiday removed', 'success')
|
||||
}
|
||||
|
||||
function showToast(message: string, color: string) {
|
||||
toast.message = message
|
||||
toast.color = color
|
||||
toast.show = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-input {
|
||||
text-align: right;
|
||||
max-width: 80px;
|
||||
}
|
||||
.add-holiday-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.holiday-date-input { flex: 0 0 160px; }
|
||||
.holiday-name-input { flex: 1; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user