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:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

View 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>
)
}

View 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>
)
}