feat: auth system, scrip scheduler, UI widgets, and new API routes
- Add session-based authentication (login page, middleware, auth context) - Add cron-like scrip scheduler for time-based conditions - Add layout builder, scrip wizard, searchable select components - Add trend chart widget for dashboards - Add notifications, attachments, queue-permissions API routes - Add seed-users script - Update schema with 10 new migrations (0008-0017) - Apply redesign: Linear-inspired dark theme, conversation-centric UI - Gitignore runtime data directory Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { useState, useEffect, Suspense, createContext, useContext } from "react"
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BellIcon,
|
||||
CircleIcon,
|
||||
LayoutGridIcon,
|
||||
UserIcon,
|
||||
@@ -15,11 +16,12 @@ import {
|
||||
PanelLeftIcon,
|
||||
CommandIcon,
|
||||
} from "lucide-react";
|
||||
import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard } from "@/lib/api";
|
||||
import type { Dashboard, Queue, SavedView, Team, User } from "@/lib/types";
|
||||
import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard, getUnreadCount, getNotifications, markNotificationRead, markAllNotificationsRead, getApiTokens, createApiToken, revokeApiToken } from "@/lib/api";
|
||||
import type { Dashboard, Queue, SavedView, Team, User, Notification, ApiToken } from "@/lib/types";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { cn, formatTicketId } from "@/lib/utils";
|
||||
|
||||
const SidebarCollapsedContext = createContext(false);
|
||||
|
||||
@@ -77,6 +79,7 @@ function SidebarNavItem({
|
||||
function SidebarNav() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { user: authUser } = useAuth();
|
||||
|
||||
const [counts, setCounts] = useState<ViewCounts>({
|
||||
all: 0,
|
||||
@@ -87,20 +90,17 @@ function SidebarNav() {
|
||||
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
|
||||
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||
const [myTeamId, setMyTeamId] = useState<string | null>(null);
|
||||
const [newDashboardName, setNewDashboardName] = useState("");
|
||||
const [addingDashboard, setAddingDashboard] = useState(false);
|
||||
|
||||
const currentUserId = authUser?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
// Find current user
|
||||
const myId = currentUserId;
|
||||
const [ticketRes, userRes] = await Promise.all([getTickets(), getUsers()]);
|
||||
const data = ticketRes.data;
|
||||
const users = userRes.data ?? [];
|
||||
const currentUser = users.find((u) => u.username !== 'system') ?? users[0] ?? null;
|
||||
const myId = currentUser?.id ?? null;
|
||||
setCurrentUserId(myId);
|
||||
|
||||
if (data) {
|
||||
const now = Date.now();
|
||||
@@ -135,9 +135,9 @@ function SidebarNav() {
|
||||
const [dashRes, teamRes] = await Promise.all([getDashboards(), getTeams()]);
|
||||
const allDashboards = dashRes.data ?? [];
|
||||
const allTeams = teamRes.data ?? [];
|
||||
const userTeams = allTeams.filter((t) =>
|
||||
(t.members ?? []).some((m) => m.id === myId)
|
||||
);
|
||||
const userTeams = myId
|
||||
? allTeams.filter((t) => (t.members ?? []).some((m) => m.id === myId))
|
||||
: [];
|
||||
setMyTeamId(userTeams[0]?.id ?? null);
|
||||
const teamIds = new Set(userTeams.map((t) => t.id));
|
||||
const visible = allDashboards.filter((d) =>
|
||||
@@ -146,7 +146,7 @@ function SidebarNav() {
|
||||
setDashboards(visible);
|
||||
}
|
||||
void load();
|
||||
}, []);
|
||||
}, [currentUserId]);
|
||||
|
||||
const collapsed = useSidebarCollapsed();
|
||||
|
||||
@@ -326,22 +326,268 @@ function SidebarNav() {
|
||||
function SidebarBottom() {
|
||||
const pathname = usePathname();
|
||||
const collapsed = useSidebarCollapsed();
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
const [tokenOpen, setTokenOpen] = useState(false);
|
||||
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||
const [newTokenName, setNewTokenName] = useState("");
|
||||
const [newTokenValue, setNewTokenValue] = useState<string | null>(null);
|
||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||
|
||||
const loadTokens = async () => {
|
||||
const { data } = await getApiTokens();
|
||||
if (data) setTokens(data);
|
||||
};
|
||||
|
||||
useEffect(() => { if (tokenOpen) { void loadTokens(); } }, [tokenOpen]);
|
||||
|
||||
const handleCreateToken = async () => {
|
||||
if (!newTokenName.trim()) return;
|
||||
setTokenError(null);
|
||||
const { data, error } = await createApiToken(newTokenName.trim());
|
||||
if (error) { setTokenError(error); return; }
|
||||
if (data) {
|
||||
setNewTokenValue(data.token);
|
||||
setNewTokenName("");
|
||||
await loadTokens();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (id: string) => {
|
||||
await revokeApiToken(id);
|
||||
await loadTokens();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-sidebar-border/50 p-2">
|
||||
<SidebarNavItem
|
||||
href="/admin"
|
||||
icon={SettingsIcon}
|
||||
label="Admin"
|
||||
active={pathname === "/admin"}
|
||||
/>
|
||||
<div className={cn("flex", collapsed ? "justify-center mt-2" : "mt-2 px-1")}>
|
||||
<div className="border-t border-sidebar-border/50 p-2 space-y-1">
|
||||
{isAdmin && (
|
||||
<SidebarNavItem
|
||||
href="/admin"
|
||||
icon={SettingsIcon}
|
||||
label="Admin"
|
||||
active={pathname === "/admin"}
|
||||
/>
|
||||
)}
|
||||
{user ? (
|
||||
<>
|
||||
{!collapsed && (
|
||||
<div className="px-2.5 py-1 text-[11px] text-sidebar-foreground/50 truncate">
|
||||
{user.username}
|
||||
{isAdmin && <span className="ml-1 text-[10px] text-sidebar-foreground/30">(admin)</span>}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setTokenOpen(true)}
|
||||
className={cn(
|
||||
"w-full flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
|
||||
"text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50",
|
||||
collapsed ? "justify-center" : ""
|
||||
)}
|
||||
>
|
||||
<span className="opacity-50">API tokens</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
className={cn(
|
||||
"w-full flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
|
||||
"text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50",
|
||||
collapsed ? "justify-center" : ""
|
||||
)}
|
||||
>
|
||||
<span className="opacity-50">Sign out</span>
|
||||
</button>
|
||||
|
||||
{/* Token dialog */}
|
||||
{tokenOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => { setTokenOpen(false); setNewTokenValue(null); }} />
|
||||
<div className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover shadow-xl">
|
||||
<div className="border-b border-border/50 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">API tokens</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
{newTokenValue ? (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<p className="text-xs font-semibold text-foreground">Token created — copy it now:</p>
|
||||
<pre className="mt-1.5 select-all rounded bg-background px-2 py-1.5 font-mono text-xs break-all">{newTokenValue}</pre>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">This won't be shown again.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={newTokenName}
|
||||
onChange={(e) => setNewTokenName(e.target.value)}
|
||||
placeholder="Token name..."
|
||||
className="h-7 flex-1 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:border-ring"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateToken(); }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateToken}
|
||||
disabled={!newTokenName.trim()}
|
||||
className="h-7 rounded-md bg-primary px-2.5 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{tokenError && <p className="text-xs text-destructive">{tokenError}</p>}
|
||||
{tokens.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{tokens.map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between rounded-md border border-border/30 px-2.5 py-1.5">
|
||||
<div>
|
||||
<p className="text-xs font-medium">{t.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Created {new Date(t.created_at).toLocaleDateString()}
|
||||
{t.last_used_at && ` · Last used ${new Date(t.last_used_at).toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRevoke(t.id)}
|
||||
className="text-[10px] text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No API tokens yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<SidebarNavItem
|
||||
href="/login"
|
||||
icon={UserIcon}
|
||||
label="Sign in"
|
||||
active={pathname === "/login"}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("flex", collapsed ? "justify-center" : "px-1")}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationBell({ collapsed, setCommandOpen }: { collapsed: boolean; setCommandOpen: (v: boolean) => void }) {
|
||||
const [unread, setUnread] = useState(0);
|
||||
const [notifs, setNotifs] = useState<Notification[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const load = async () => {
|
||||
const [countRes, notifRes] = await Promise.all([getUnreadCount(), getNotifications()]);
|
||||
if (countRes.data) setUnread(countRes.data.count);
|
||||
if (notifRes.data) setNotifs(notifRes.data);
|
||||
};
|
||||
void load();
|
||||
// Poll every 30s
|
||||
const interval = setInterval(() => { void load(); }, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [user]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
await markNotificationRead(id);
|
||||
setUnread((c) => Math.max(0, c - 1));
|
||||
setNotifs((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
|
||||
};
|
||||
|
||||
const handleMarkAll = async () => {
|
||||
await markAllNotificationsRead();
|
||||
setUnread(0);
|
||||
setNotifs((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="relative flex h-7 w-7 items-center justify-center rounded text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50 transition-colors"
|
||||
>
|
||||
<BellIcon className="h-4 w-4" />
|
||||
{unread > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-bold text-destructive-foreground">
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 top-full z-40 mt-1 w-80 rounded-lg border border-border bg-popover shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
|
||||
<span className="text-xs font-semibold text-foreground">Notifications</span>
|
||||
{unread > 0 && (
|
||||
<button onClick={handleMarkAll} className="text-[10px] text-muted-foreground hover:text-foreground">
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-80 overflow-auto">
|
||||
{notifs.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
No notifications yet.
|
||||
</div>
|
||||
) : (
|
||||
notifs.slice(0, 20).map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => {
|
||||
handleMarkRead(n.id);
|
||||
if (n.ticket_id) window.location.href = `/tickets/${n.ticket_id}`;
|
||||
}}
|
||||
className={cn(
|
||||
"w-full border-b border-border/30 px-3 py-2.5 text-left transition-colors hover:bg-accent/30",
|
||||
!n.read && "bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"mt-0.5 h-2 w-2 shrink-0 rounded-full",
|
||||
n.read ? "bg-border" : "bg-primary"
|
||||
)} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-foreground">{n.title}</p>
|
||||
{n.body && (
|
||||
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{n.body}</p>
|
||||
)}
|
||||
{n.ticket_id && (
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
|
||||
{formatTicketId(n.ticket_id)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
<button
|
||||
onClick={() => setCommandOpen(true)}
|
||||
className="flex h-6 items-center gap-1 rounded border border-sidebar-border/50 px-1.5 text-[10px] text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/60"
|
||||
aria-label="Open command palette"
|
||||
>
|
||||
<CommandIcon className="h-3 w-3" />K
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
@@ -384,15 +630,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
<span className="text-sm font-semibold text-sidebar-foreground tracking-tight">Tessera</span>
|
||||
)}
|
||||
</Link>
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
onClick={() => setCommandOpen(true)}
|
||||
className="flex h-6 items-center gap-1 rounded border border-sidebar-border/50 px-1.5 text-[10px] text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/60"
|
||||
aria-label="Open command palette"
|
||||
>
|
||||
<CommandIcon className="h-3 w-3" />K
|
||||
</button>
|
||||
)}
|
||||
<NotificationBell collapsed={sidebarCollapsed} setCommandOpen={setCommandOpen} />
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
|
||||
Reference in New Issue
Block a user