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 { StatusesProvider } from './context/StatusesContext'
import { CategoriesProvider } from './context/CategoriesContext' import { CategoriesProvider } from './context/CategoriesContext'
import { ProjectsProvider } from './context/ProjectsContext' import { ProjectsProvider } from './context/ProjectsContext'
import { UsersProvider } from './context/UsersContext'
import { SearchProvider } from './context/SearchContext' import { SearchProvider } from './context/SearchContext'
import { TaskFiltersProvider } from './context/TaskFiltersContext' import { TaskFiltersProvider } from './context/TaskFiltersContext'
import { TaskDrawerProvider } from './components/TaskDrawer' import { TaskDrawerProvider } from './components/TaskDrawer'
@ -30,6 +31,7 @@ export default function App() {
<StatusesProvider> <StatusesProvider>
<CategoriesProvider> <CategoriesProvider>
<ProjectsProvider> <ProjectsProvider>
<UsersProvider>
<SearchProvider> <SearchProvider>
<TaskFiltersProvider> <TaskFiltersProvider>
<TaskDrawerProvider> <TaskDrawerProvider>
@ -51,6 +53,7 @@ export default function App() {
</TaskDrawerProvider> </TaskDrawerProvider>
</TaskFiltersProvider> </TaskFiltersProvider>
</SearchProvider> </SearchProvider>
</UsersProvider>
</ProjectsProvider> </ProjectsProvider>
</CategoriesProvider> </CategoriesProvider>
</StatusesProvider> </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' 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 }) }) 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 ─────────────────────────────────────────────────── // ── Config ───────────────────────────────────────────────────
export function fetchConfig(): Promise<AppConfig> { export function fetchConfig(): Promise<AppConfig> {
return request('/config') return request('/config')

View file

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

View file

@ -6,6 +6,7 @@ import { useCategories } from '../context/CategoriesContext'
import { useProjects } from '../context/ProjectsContext' import { useProjects } from '../context/ProjectsContext'
import { useLists } from '../context/ListsContext' import { useLists } from '../context/ListsContext'
import { useTaskFilters } from '../context/TaskFiltersContext' import { useTaskFilters } from '../context/TaskFiltersContext'
import { useUsers } from '../context/UsersContext'
export function PriorityBadge({ priority }: { priority: Priority }) { export function PriorityBadge({ priority }: { priority: Priority }) {
if (priority === 'medium') return null 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 } interface FilterableEntity { id: number; name: string; colour: string }
// Collapsible multi-select filter chip-bar — generic over anything with an // 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 { useSearch } from '../context/SearchContext'
import { useTaskFilters } from '../context/TaskFiltersContext' import { useTaskFilters } from '../context/TaskFiltersContext'
import { can } from '../types' 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 // A flat, cross-user list for the 'reallocate' cap — deliberately not routed
// through the shared task drawer, since GET /api/tasks/:id only grants // through the shared task drawer, since GET /api/tasks/:id only grants
@ -83,10 +83,8 @@ export default function DispatchView() {
</div> </div>
{movingId === t.id ? ( {movingId === t.id ? (
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
<input type="email" autoFocus value={target} placeholder="reallocate to…" style={{ width: 180 }} <UserPicker value={target} onChange={setTarget} onSubmit={() => move(t.id)} buttonLabel="Move"
onChange={e => setTarget(e.target.value)} exclude={[t.owner_id]} placeholder="reallocate to…" />
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>
<button className="btn btn-sm" onClick={() => setMovingId(null)}>Cancel</button> <button className="btn btn-sm" onClick={() => setMovingId(null)}>Cancel</button>
</div> </div>
) : ( ) : (

View file

@ -141,6 +141,13 @@ export interface User {
caps: string[] // bare slugs — verify?app=tasks strips the prefix 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 { export function can(user: User, cap: string): boolean {
return user.is_admin || user.caps.includes(cap) return user.is_admin || user.caps.includes(cap)
} }