Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ migration/out/

# The end-to-end run's database lives in /tmp, outside the tree the dev server watches (0029).

../../../../../tmp/nnt-e2e-3101
../../../../../tmp/nnt-e2e-3101

# `bun run shots` writes here. The pictures are for looking at once, not for keeping.
.shots/
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ emptying the database between suites, and names each suite as it starts so a slo
suite is slow (0029). CI gates on the first; the second runs nightly and on demand.
`bun test <file>` still runs one suite on its own.

### Developer tools

Running locally, `/dev` seeds a persona per role plus a plain member, a guest and a tombstone,
signs in as any of them without a password, and shows the local mailbox alongside the permissions
the current session resolves to. It is an authentication bypass, so it does not exist in a build:
`nuxt.config` leaves the page and its routes out of the bundle, and `tests/unit/dev-tools.test.ts`
greps a built `.output` to prove it (K-124).

`bun run shots` writes a picture of every admin screen at two widths into `.shots/`, which is
gitignored. It gates nothing and CI never runs it; it is there so a visual change can be reviewed
from the images rather than from the diff.

The migration tooling is standalone and has its own instructions in
[`migration/README.md`](migration/README.md); the application never imports from it.

Expand Down
96 changes: 96 additions & 0 deletions app/components/AdminToolbar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<script setup lang="ts">
// One shape for every admin list: a search of fixed width, the filters behind one button so the row
// cannot resize, and what is filtered shown as chips (0032).

export interface ActiveFilter {
key: string
label: string
icon?: string
clear: () => void
}

const search = defineModel<string>('search', { default: '' })

withDefaults(defineProps<{
placeholder?: string
active?: ActiveFilter[]
loading?: boolean
// A page with nothing behind the button says so rather than offering an empty panel.
filterable?: boolean
}>(), {
placeholder: 'Search',
active: () => [],
loading: false,
filterable: true,
})

const emit = defineEmits<{ clear: [] }>()
</script>

<template>
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<UInput
v-model="search"
icon="i-lucide-search"
:placeholder="placeholder"
:loading="loading"
class="w-full sm:w-80"
data-test="toolbar-search"
/>

<UPopover v-if="filterable">
<UButton
icon="i-lucide-sliders-horizontal"
color="neutral"
variant="outline"
data-test="toolbar-filters"
:label="active.length ? `Filters (${active.length})` : 'Filters'"
/>

<template #content>
<div class="w-80 max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto p-4">
<slot name="filters" />
</div>
</template>
</UPopover>

<div class="ms-auto flex flex-wrap items-center gap-2">
<slot name="actions" />
</div>
</div>

<div
v-if="active.length"
class="flex flex-wrap items-center gap-2"
data-test="toolbar-active"
>
<UBadge
v-for="filter in active"
:key="filter.key"
:icon="filter.icon"
color="neutral"
variant="subtle"
>
{{ filter.label }}
<UButton
icon="i-lucide-x"
size="xs"
variant="ghost"
color="neutral"
:aria-label="`Clear ${filter.label}`"
@click="filter.clear()"
/>
</UBadge>

<UButton
label="Clear all"
size="xs"
color="neutral"
variant="ghost"
data-test="toolbar-clear"
@click="emit('clear')"
/>
</div>
</div>
</template>
13 changes: 12 additions & 1 deletion app/components/AuthStatus.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<script setup lang="ts">
const { account, refresh } = useAccount()

// The sidebar footer is 210px wide, so side by side there wraps the name and the button both.
defineProps<{ stacked?: boolean }>()

async function signOut(): Promise<void> {
await $fetch('/api/auth/sign-out', { method: 'POST' })
await refresh()
Expand All @@ -9,19 +12,27 @@ async function signOut(): Promise<void> {
</script>

<template>
<div class="flex items-center gap-2">
<div
class="flex gap-2"
:class="stacked ? 'w-full flex-col items-stretch' : 'items-center'"
>
<template v-if="account.signedIn">
<UButton
size="sm"
variant="ghost"
to="/account/security"
:block="stacked"
:class="stacked ? 'justify-start truncate' : ''"
>
{{ account.user?.name }}
</UButton>
<UButton
data-test="sign-out"
size="sm"
variant="ghost"
:block="stacked"
:class="stacked ? 'justify-start' : ''"
icon="i-lucide-log-out"
@click="signOut"
>
Sign out
Expand Down
57 changes: 57 additions & 0 deletions app/components/DateField.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { CalendarDate, parseDate } from '@internationalized/date'

// The one place that converts between the YYYY-MM-DD strings the API speaks and the calendar value
// the input takes. British order, because every domain date here is London's (0014, 0032).

const model = defineModel<string | undefined>()

defineProps<{ disabled?: boolean }>()

const field = useTemplateRef('field')

const value = computed({
get(): CalendarDate | null {
if (!model.value) return null
try {
return parseDate(model.value)
}
catch {
return null
}
},
set(next: unknown) {
model.value = next instanceof CalendarDate ? next.toString() : undefined
},
})
</script>

<template>
<UInputDate
ref="field"
v-model="value"
locale="en-GB"
:disabled="disabled"
>
<template #trailing>
<UPopover :reference="field?.inputsRef?.at(-1)?.$el">
<UButton
color="neutral"
variant="link"
size="sm"
icon="i-lucide-calendar"
aria-label="Pick a date from a calendar"
:disabled="disabled"
class="px-0"
/>

<template #content>
<UCalendar
v-model="value"
class="p-2"
/>
</template>
</UPopover>
</template>
</UInputDate>
</template>
120 changes: 120 additions & 0 deletions app/components/PersonPicker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<script setup lang="ts">
// A person is chosen, never typed (0032). Searches name, address and student number, because a
// name may not match the SU's record and the address is often personal (0031).

interface Person {
id: string
name: string
email: string
studentId: string | null
anonymisedAt: number | null
}

interface Listing { items: Person[] }

interface Item {
label: string
value: string
email: string
hint: string | null
erased: boolean
}

const model = defineModel<string | undefined>()

const props = withDefaults(defineProps<{
placeholder?: string
disabled?: boolean
// A tombstone is a real account and a valid target for some things, and never for others.
includeErased?: boolean
}>(), {
placeholder: 'Search by name, address or student number',
disabled: false,
includeErased: false,
})

const searchTerm = ref('')
const settled = useDebounced(searchTerm, 250)

// The account directory: it already pages and allow-lists its columns. Never cached, because a
// remembered answer would offer somebody since renamed or erased.
const instance = useId()
const { data, status } = await useAsyncData(
() => `person-picker-${instance}-${settled.value}`,
() => settled.value.trim().length < 2
? Promise.resolve({ items: [] } as Listing)
: $fetch<Listing>('/api/admin/accounts', {
query: { search: settled.value.trim(), pageSize: 10, includeAnonymised: props.includeErased },
}),
{ watch: [settled], default: (): Listing => ({ items: [] }), getCachedData: () => undefined },
)

// Held separately so the chosen person still reads as a name after the search that found them has
// been cleared.
const chosen = ref<Item | null>(null)

const items = computed<Item[]>(() => (data.value?.items ?? []).map(person => ({
label: person.name,
value: person.id,
email: person.email,
hint: person.studentId,
erased: person.anonymisedAt !== null,
})))

const shown = computed<Item[]>(() =>
chosen.value && !items.value.some(item => item.value === chosen.value!.value)
? [chosen.value, ...items.value]
: items.value)

function choose(item: Item | undefined): void {
chosen.value = item ?? null
model.value = item?.value
}

// A form that resets its state clears the name too, rather than showing the last person picked.
watch(model, (value) => {
if (!value) chosen.value = null
})
</script>

<template>
<div data-test="person-picker">
<UInputMenu
class="w-full"
:model-value="shown.find(item => item.value === model)"
:items="shown"
:loading="status === 'pending'"
:disabled="disabled"
:placeholder="placeholder"
:search-input="{ icon: 'i-lucide-search', placeholder: 'Name, address or student number' }"
:content="{ hideWhenEmpty: true }"
ignore-filter
icon="i-lucide-user"
@update:model-value="choose"
@update:search-term="value => searchTerm = value"
>
<template #item-label="{ item }">
<span class="flex flex-col">
<span class="flex items-center gap-1.5">
{{ item.label }}
<UBadge
v-if="item.erased"
color="neutral"
variant="subtle"
size="sm"
>
Erased
</UBadge>
</span>
<span class="font-mono text-xs text-muted">{{ item.hint ?? item.email }}</span>
</span>
</template>

<template #empty>
<span class="text-sm text-muted">
{{ searchTerm.trim().length < 2 ? 'Type at least two characters' : 'Nobody matches that' }}
</span>
</template>
</UInputMenu>
</div>
</template>
21 changes: 21 additions & 0 deletions app/composables/useDebounced.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Ref } from 'vue'

// A search that fires on every keystroke asks the server a question the typist has not finished.
// Small enough to own rather than take a dependency for.
export function useDebounced<T>(source: Ref<T>, delayMs = 250): Ref<T> {
const settled = ref(source.value) as Ref<T>
let timer: ReturnType<typeof setTimeout> | undefined

watch(source, (value) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
settled.value = value
}, delayMs)
})

onScopeDispose(() => {
if (timer) clearTimeout(timer)
})

return settled
}
7 changes: 5 additions & 2 deletions app/layouts/admin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ const links = [[
{ label: 'Fellows', icon: 'i-lucide-award', to: '/admin/fellows' },
{ label: 'Audit trail', icon: 'i-lucide-scroll-text', to: '/admin/audit' },
{ label: 'Settings', icon: 'i-lucide-settings', to: '/admin/config' },
]]
], // Development only, and absent from a build because the page it points at is (K-124).
...import.meta.dev
? [[{ label: 'Developer tools', icon: 'i-lucide-flask-conical', to: '/dev' }]]
: []]
const route = useRoute()
</script>

Expand All @@ -26,7 +29,7 @@ const route = useRoute()
:items="links"
/>
<template #footer>
<AuthStatus />
<AuthStatus stacked />
</template>
</UDashboardSidebar>

Expand Down
Loading
Loading