Replace freetext email inputs with a user lookup for tag/assign/reallocate

Typo'd addresses in the old <input type="email"> fields silently matched
nobody (assigned_to/task_tags.user_email have no FK), so a task could vanish
into an unreachable owner with no error. New UsersContext + UserPicker pull
the real user list from central auth (/api/auth/users?app=tasks), same
pattern maintenance already uses for its assignee dropdown.
This commit is contained in:
jtricerolph 2026-10-12 13:59:54 +00:00
parent 3145c1fc07
commit 6172617df3
7 changed files with 103 additions and 46 deletions

View file

@ -8,6 +8,7 @@ import { ListsProvider } from './context/ListsContext'
import { StatusesProvider } from './context/StatusesContext'
import { CategoriesProvider } from './context/CategoriesContext'
import { ProjectsProvider } from './context/ProjectsContext'
import { UsersProvider } from './context/UsersContext'
import { SearchProvider } from './context/SearchContext'
import { TaskFiltersProvider } from './context/TaskFiltersContext'
import { TaskDrawerProvider } from './components/TaskDrawer'
@ -30,6 +31,7 @@ export default function App() {
<StatusesProvider>
<CategoriesProvider>
<ProjectsProvider>
<UsersProvider>
<SearchProvider>
<TaskFiltersProvider>
<TaskDrawerProvider>
@ -51,6 +53,7 @@ export default function App() {
</TaskDrawerProvider>
</TaskFiltersProvider>
</SearchProvider>
</UsersProvider>
</ProjectsProvider>
</CategoriesProvider>
</StatusesProvider>

View file

@ -1,4 +1,4 @@
import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail } from './types'
import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail, AuthUser } from './types'
const BASE = '/tasks/api'
@ -137,6 +137,11 @@ export function addComment(taskId: number, body: string): Promise<TaskComment> {
return request(`/tasks/${taskId}/comments`, { method: 'POST', body: JSON.stringify({ body }) })
}
// ── Users (for tag/assign/reallocate pickers) ───────────────────
export function fetchAssignableUsers(): Promise<AuthUser[]> {
return request('/auth/users?app=tasks')
}
// ── Config ───────────────────────────────────────────────────
export function fetchConfig(): Promise<AppConfig> {
return request('/config')

View file

@ -13,7 +13,7 @@ import { useProjects } from '../context/ProjectsContext'
import { useStatuses } from '../context/StatusesContext'
import { useAuth } from './AuthGate'
import { can } from '../types'
import { Check_, formatDateTime, toLocalInput } from './shared'
import { Check_, formatDateTime, toLocalInput, UserPicker } from './shared'
export default function TaskDetail({ taskId, onChange, onClose }:
{ taskId: number; onChange: () => void; onClose: () => void }) {
@ -226,24 +226,16 @@ export default function TaskDetail({ taskId, onChange, onClose }:
</div>
)}
{isOwner && (
<div style={{ display: 'flex', gap: 6 }}>
<input type="email" value={tagEmail} placeholder="colleague@hotel.com"
onChange={e => setTagEmail(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addTag() }} />
<button className="btn btn-sm" onClick={addTag} disabled={!tagEmail.trim()}>Tag</button>
</div>
<UserPicker value={tagEmail} onChange={setTagEmail} onSubmit={addTag} buttonLabel="Tag"
exclude={[user.email, ...task.tags.map(tg => tg.user_email)]} />
)}
</div>
{isOwner && can(user, 'assign') && (
<div className="field" style={{ marginBottom: can(user, 'reallocate') ? 8 : 0 }}>
<label><UserPlus size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Assign (hands off ownership)</label>
<div style={{ display: 'flex', gap: 6 }}>
<input type="email" value={assignEmail} placeholder={task.assigned_to ?? 'someone@hotel.com'}
onChange={e => setAssignEmail(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') assignTo() }} />
<button className="btn btn-sm" onClick={assignTo} disabled={!assignEmail.trim()}>Assign</button>
</div>
<UserPicker value={assignEmail} onChange={setAssignEmail} onSubmit={assignTo} buttonLabel="Assign"
exclude={[user.email]} />
{task.assigned_to && <div className="field-hint">Currently assigned to {task.assigned_to}</div>}
</div>
)}
@ -251,12 +243,7 @@ export default function TaskDetail({ taskId, onChange, onClose }:
{can(user, 'reallocate') && (
<div className="field" style={{ marginBottom: 0 }}>
<label><Send size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Reallocate (dispatcher — moves to anyone)</label>
<div style={{ display: 'flex', gap: 6 }}>
<input type="email" value={reallocateEmail} placeholder="someone@hotel.com"
onChange={e => setReallocateEmail(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') reallocateTo() }} />
<button className="btn btn-sm" onClick={reallocateTo} disabled={!reallocateEmail.trim()}>Move</button>
</div>
<UserPicker value={reallocateEmail} onChange={setReallocateEmail} onSubmit={reallocateTo} buttonLabel="Move" />
</div>
)}

View file

@ -6,6 +6,7 @@ import { useCategories } from '../context/CategoriesContext'
import { useProjects } from '../context/ProjectsContext'
import { useLists } from '../context/ListsContext'
import { useTaskFilters } from '../context/TaskFiltersContext'
import { useUsers } from '../context/UsersContext'
export function PriorityBadge({ priority }: { priority: Priority }) {
if (priority === 'medium') return null
@ -22,6 +23,30 @@ export function Check_({ isDone, onClick }: { isDone: boolean; onClick: (e: Reac
)
}
// User lookup + submit button, replacing a freetext email input wherever a
// task gets tagged/assigned/reallocated — avoids typo'd addresses silently
// matching nobody (assigned_to/task_tags.user_email have no FK to users).
export function UserPicker({ value, onChange, onSubmit, exclude = [], placeholder = 'Choose a person…', buttonLabel }: {
value: string
onChange: (email: string) => void
onSubmit: () => void
exclude?: string[]
placeholder?: string
buttonLabel: string
}) {
const { users } = useUsers()
const options = users.filter(u => !exclude.includes(u.email))
return (
<div style={{ display: 'flex', gap: 6 }}>
<select value={value} onChange={e => onChange(e.target.value)}>
<option value="">{placeholder}</option>
{options.map(u => <option key={u.email} value={u.email}>{u.name} ({u.email})</option>)}
</select>
<button className="btn btn-sm" onClick={onSubmit} disabled={!value}>{buttonLabel}</button>
</div>
)
}
interface FilterableEntity { id: number; name: string; colour: string }
// Collapsible multi-select filter chip-bar — generic over anything with an

View file

@ -0,0 +1,32 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import type { AuthUser } from '../types'
import { fetchAssignableUsers } from '../api'
interface UsersCtx {
users: AuthUser[]
loading: boolean
reload: () => Promise<void>
}
const Ctx = createContext<UsersCtx | null>(null)
export function useUsers() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useUsers must be used inside UsersProvider')
return ctx
}
export function UsersProvider({ children }: { children: React.ReactNode }) {
const [users, setUsers] = useState<AuthUser[]>([])
const [loading, setLoading] = useState(true)
const reload = useCallback(async () => {
const data = await fetchAssignableUsers().catch(() => [])
setUsers(data)
setLoading(false)
}, [])
useEffect(() => { reload() }, [reload])
return <Ctx.Provider value={{ users, loading, reload }}>{children}</Ctx.Provider>
}

View file

@ -6,7 +6,7 @@ import { useAuth } from '../components/AuthGate'
import { useSearch } from '../context/SearchContext'
import { useTaskFilters } from '../context/TaskFiltersContext'
import { can } from '../types'
import { dueInfo, PriorityBadge, TaskFilterBar } from '../components/shared'
import { dueInfo, PriorityBadge, TaskFilterBar, UserPicker } from '../components/shared'
// A flat, cross-user list for the 'reallocate' cap — deliberately not routed
// through the shared task drawer, since GET /api/tasks/:id only grants
@ -83,10 +83,8 @@ export default function DispatchView() {
</div>
{movingId === t.id ? (
<div style={{ display: 'flex', gap: 6 }}>
<input type="email" autoFocus value={target} placeholder="reallocate to…" style={{ width: 180 }}
onChange={e => setTarget(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') move(t.id); if (e.key === 'Escape') setMovingId(null) }} />
<button className="btn btn-sm btn-primary" onClick={() => move(t.id)} disabled={!target.trim()}>Move</button>
<UserPicker value={target} onChange={setTarget} onSubmit={() => move(t.id)} buttonLabel="Move"
exclude={[t.owner_id]} placeholder="reallocate to…" />
<button className="btn btn-sm" onClick={() => setMovingId(null)}>Cancel</button>
</div>
) : (

View file

@ -141,6 +141,13 @@ export interface User {
caps: string[] // bare slugs — verify?app=tasks strips the prefix
}
// Users with access to this app, served by the central auth service.
export interface AuthUser {
id: number
email: string
name: string
}
export function can(user: User, cap: string): boolean {
return user.is_admin || user.caps.includes(cap)
}