feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
136
frontend/src/components/users/CreateUserForm.tsx
Normal file
136
frontend/src/components/users/CreateUserForm.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usersApi } from '@/lib/api'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface Props {
|
||||
tenantId: string
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CreateUserForm({ tenantId, open, onClose }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const [form, setForm] = useState({ name: '', email: '', password: '', role: 'viewer' })
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => usersApi.create(tenantId, form),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['users', tenantId] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['tenants'] })
|
||||
toast({ title: `User "${form.email}" created` })
|
||||
handleClose()
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg =
|
||||
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||
?? 'Failed to create user'
|
||||
setError(msg)
|
||||
},
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
setForm({ name: '', email: '', password: '', role: 'viewer' })
|
||||
setError(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.name.trim() || !form.email.trim() || !form.password.trim()) {
|
||||
setError('All fields are required')
|
||||
return
|
||||
}
|
||||
if (form.password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="user-name">Full Name *</Label>
|
||||
<Input
|
||||
id="user-name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Jane Smith"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="user-email">Email *</Label>
|
||||
<Input
|
||||
id="user-email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
placeholder="jane@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="user-password">Password *</Label>
|
||||
<Input
|
||||
id="user-password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
placeholder="Min. 8 characters"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="user-role">Role</Label>
|
||||
<Select
|
||||
value={form.role}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, role: v }))}
|
||||
>
|
||||
<SelectTrigger id="user-role">
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tenant_admin">Admin</SelectItem>
|
||||
<SelectItem value="operator">Operator</SelectItem>
|
||||
<SelectItem value="viewer">Viewer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{error && <p className="text-xs text-error">{error}</p>}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={handleClose} size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Creating...' : 'Add User'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
153
frontend/src/components/users/UserList.tsx
Normal file
153
frontend/src/components/users/UserList.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, UserX } from 'lucide-react'
|
||||
import { usersApi } from '@/lib/api'
|
||||
import { useAuth, isTenantAdmin } from '@/lib/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatDateTime } from '@/lib/utils'
|
||||
import { CreateUserForm } from './CreateUserForm'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { TableSkeleton } from '@/components/ui/page-skeleton'
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
super_admin: '#7c3aed',
|
||||
tenant_admin: '#2563eb',
|
||||
operator: '#059669',
|
||||
viewer: '#6b7280',
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
super_admin: 'Super Admin',
|
||||
tenant_admin: 'Admin',
|
||||
operator: 'Operator',
|
||||
viewer: 'Viewer',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tenantId: string
|
||||
}
|
||||
|
||||
export function UserList({ tenantId }: Props) {
|
||||
const { user: currentUser } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['users', tenantId],
|
||||
queryFn: () => usersApi.list(tenantId),
|
||||
})
|
||||
|
||||
const deactivateMutation = useMutation({
|
||||
mutationFn: (userId: string) => usersApi.deactivate(tenantId, userId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['users', tenantId] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['tenants'] })
|
||||
toast({ title: 'User deactivated' })
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to deactivate user', variant: 'destructive' })
|
||||
},
|
||||
})
|
||||
|
||||
const handleDeactivate = (userId: string, email: string) => {
|
||||
if (confirm(`Deactivate user "${email}"?`)) {
|
||||
deactivateMutation.mutate(userId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Users</h2>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
{users?.filter((u) => u.is_active).length ?? 0} active
|
||||
</p>
|
||||
</div>
|
||||
{isTenantAdmin(currentUser) && (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add User
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface">
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-text-muted">Name</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-text-muted">Email</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-text-muted">Role</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-text-muted">Last Login</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-text-muted">Status</th>
|
||||
{isTenantAdmin(currentUser) && (
|
||||
<th className="px-3 py-2 text-xs font-medium text-text-muted w-8"></th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-4">
|
||||
<TableSkeleton rows={5} />
|
||||
</td>
|
||||
</tr>
|
||||
) : users?.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-8 text-center text-text-muted">
|
||||
No users in this tenant
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users?.map((u) => (
|
||||
<tr
|
||||
key={u.id}
|
||||
className="border-b border-border/50 hover:bg-surface transition-colors"
|
||||
>
|
||||
<td className="px-3 py-2.5 font-medium">{u.name}</td>
|
||||
<td className="px-3 py-2.5 text-text-secondary">{u.email}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge color={ROLE_COLORS[u.role]}>
|
||||
{ROLE_LABELS[u.role] ?? u.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-text-muted text-xs">
|
||||
{formatDateTime(u.last_login)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{u.is_active ? (
|
||||
<span className="text-xs text-success">Active</span>
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">Inactive</span>
|
||||
)}
|
||||
</td>
|
||||
{isTenantAdmin(currentUser) && (
|
||||
<td className="px-3 py-2.5">
|
||||
{u.is_active && u.id !== currentUser?.id && (
|
||||
<button
|
||||
onClick={() => handleDeactivate(u.id, u.email)}
|
||||
className="text-text-muted hover:text-error transition-colors"
|
||||
title="Deactivate user"
|
||||
>
|
||||
<UserX className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CreateUserForm
|
||||
tenantId={tenantId}
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user