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:
Gjermund Høsøien Wiggen
2026-06-15 20:42:17 +02:00
parent 1d4dc38d06
commit 70f0924d4b
59 changed files with 21795 additions and 321 deletions

View File

@@ -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 */}