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

@@ -17,15 +17,30 @@ import type {
QueueCustomField,
PreviewResult,
UpdateResult,
Attachment,
AttachmentUploadResult,
TicketLink,
LoginResult,
} from "./types";
const BASE_URL = "/api";
async function request<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
try {
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
// Merge with options headers if any
const opts = { ...options };
if (opts.headers) {
Object.assign(headers, opts.headers as Record<string, string>);
delete opts.headers;
}
const res = await fetch(`${BASE_URL}${url}`, {
headers: { "Content-Type": "application/json" },
...options,
headers,
...opts,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
@@ -45,6 +60,9 @@ export async function getTickets(params?: {
owner_id?: string;
team_id?: string;
custom_fields?: Record<string, string>;
subject?: string;
created?: string;
updated?: string;
}): Promise<{ data: Ticket[] | null; error: string | null }> {
const sp = new URLSearchParams();
if (params?.queue_id) sp.set("queue_id", params.queue_id);
@@ -52,6 +70,9 @@ export async function getTickets(params?: {
if (params?.q) sp.set("q", params.q);
if (params?.owner_id) sp.set("owner_id", params.owner_id);
if (params?.team_id) sp.set("team_id", params.team_id);
if (params?.subject) sp.set("subject", params.subject);
if (params?.created) sp.set("created", params.created);
if (params?.updated) sp.set("updated", params.updated);
if (params?.custom_fields) {
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
if (value) sp.set(`cf.${fieldId}`, value);
@@ -86,10 +107,80 @@ export async function getTicketTransactions(id: number): Promise<{ data: Transac
return request<Transaction[]>(`/tickets/${id}/transactions`);
}
export async function sendComment(id: number, data: { body: string; internal?: boolean }): Promise<{ data: Transaction | null; error: string | null }> {
export async function sendComment(id: number, data: { body: string; internal?: boolean; attachment_ids?: string[]; time_worked_minutes?: number }): Promise<{ data: Transaction | null; error: string | null }> {
return request<Transaction>(`/tickets/${id}/comment`, { method: "POST", body: JSON.stringify(data) });
}
export async function batchUpdateTickets(data: {
ticket_ids: number[];
status?: string;
owner_id?: string | null;
team_id?: string | null;
}): Promise<{ data: { results: Array<{ id: number; ok: boolean; error?: string }> } | null; error: string | null }> {
return request<{ results: Array<{ id: number; ok: boolean; error?: string }> }>("/tickets/batch", { method: "POST", body: JSON.stringify(data) });
}
export async function mergeTickets(sourceId: number, targetTicketId: number): Promise<{ data: { ok: boolean; target_id: number } | null; error: string | null }> {
return request<{ ok: boolean; target_id: number }>(`/tickets/${sourceId}/merge`, { method: "POST", body: JSON.stringify({ target_ticket_id: targetTicketId }) });
}
// Notifications
export interface Notification {
id: string;
user_id: string;
ticket_id: number | null;
type: string;
title: string;
body: string | null;
read: boolean;
created_at: string;
}
export async function getNotifications(): Promise<{ data: Notification[] | null; error: string | null }> {
return request<Notification[]>("/notifications");
}
export async function getUnreadCount(): Promise<{ data: { count: number } | null; error: string | null }> {
return request<{ count: number }>("/notifications/unread-count");
}
export async function markNotificationRead(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/notifications/${id}/read`, { method: "PATCH" });
}
export async function markAllNotificationsRead(): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>("/notifications/read-all", { method: "PATCH" });
}
// API Tokens
export interface ApiToken {
id: string;
name: string;
last_used_at: string | null;
created_at: string;
}
export interface ApiTokenCreated {
id: string;
name: string;
token: string;
created_at: string;
}
export async function getApiTokens(): Promise<{ data: ApiToken[] | null; error: string | null }> {
return request<ApiToken[]>("/auth/tokens");
}
export async function createApiToken(name: string): Promise<{ data: ApiTokenCreated | null; error: string | null }> {
return request<ApiTokenCreated>("/auth/tokens", { method: "POST", body: JSON.stringify({ name }) });
}
export async function revokeApiToken(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/auth/tokens/${id}`, { method: "DELETE" });
}
export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> {
return request<Queue[]>("/queues");
}
@@ -101,6 +192,8 @@ export async function getUsers(): Promise<{ data: User[] | null; error: string |
export async function createUser(data: {
username: string;
email?: string | null;
role?: string;
password?: string | null;
}): Promise<{ data: User | null; error: string | null }> {
return request<User>("/users", { method: "POST", body: JSON.stringify(data) });
}
@@ -108,6 +201,8 @@ export async function createUser(data: {
export async function updateUser(id: string, data: {
username?: string;
email?: string | null;
role?: string;
password?: string | null;
}): Promise<{ data: User | null; error: string | null }> {
return request<User>(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
@@ -268,7 +363,7 @@ export async function createView(data: {
name: string;
filters: { field: string; operator: string; value: string }[];
sort_key?: string;
columns?: { key: string; label: string; width: number; visible: boolean }[];
columns?: { key: string; label: string; width: number; display: string }[];
is_public?: boolean;
}): Promise<{ data: SavedView | null; error: string | null }> {
return request<SavedView>("/views", { method: "POST", body: JSON.stringify(data) });
@@ -373,3 +468,207 @@ export async function addTeamMember(teamId: string, userId: string): Promise<{ d
export async function removeTeamMember(teamId: string, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/teams/${teamId}/members/${userId}`, { method: "DELETE" });
}
export async function uploadAttachments(
ticketId: number,
files: File[],
): Promise<{ data: { attachments: AttachmentUploadResult[] } | null; error: string | null }> {
try {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`/api/tickets/${ticketId}/attachments`, {
method: "POST",
headers,
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
return { data: null, error: body.error || body.message || `HTTP ${res.status}` };
}
const data = await res.json();
return { data, error: null };
} catch (err) {
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
}
}
export function getAttachmentUrl(attachmentId: string): string {
return `/api/attachments/${attachmentId}`;
}
export async function getTicketAttachments(
ticketId: number,
): Promise<{ data: Attachment[] | null; error: string | null }> {
return request<Attachment[]>(`/tickets/${ticketId}/attachments`);
}
export async function getTicketLinks(
ticketId: number,
): Promise<{ data: TicketLink[] | null; error: string | null }> {
return request<TicketLink[]>(`/tickets/${ticketId}/links`);
}
export async function createTicketLink(
ticketId: number,
data: { target_ticket_id: number; link_type: string },
): Promise<{ data: TicketLink | null; error: string | null }> {
return request<TicketLink>(`/tickets/${ticketId}/links`, { method: "POST", body: JSON.stringify(data) });
}
export async function deleteTicketLink(
ticketId: number,
linkId: string,
): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/tickets/${ticketId}/links/${linkId}`, { method: "DELETE" });
}
// Queue Permissions (admin)
export interface QueuePermission {
id: string;
queue_id: string;
team_id: string;
right_name: string;
team_name?: string;
queue_name?: string;
}
export async function getQueuePermissions(): Promise<{ data: QueuePermission[] | null; error: string | null }> {
return request<QueuePermission[]>("/queue-permissions");
}
export async function getTeamsAndQueues(): Promise<{ data: { teams: Team[]; queues: Queue[] } | null; error: string | null }> {
return request<{ teams: Team[]; queues: Queue[] }>("/queue-permissions/teams-and-queues");
}
export async function grantQueuePermission(
queue_id: string,
team_id: string,
right_name: string,
): Promise<{ data: QueuePermission | null; error: string | null }> {
return request<QueuePermission>("/queue-permissions", {
method: "POST",
body: JSON.stringify({ queue_id, team_id, right_name }),
});
}
export async function revokeQueuePermission(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/queue-permissions/${id}`, { method: "DELETE" });
}
// User Permissions (admin)
export interface UserPermission {
id: string;
queue_id: string;
user_id: string;
right_name: string;
username?: string;
queue_name?: string;
}
export async function getUserPermissions(): Promise<{ data: UserPermission[] | null; error: string | null }> {
return request<UserPermission[]>("/user-permissions");
}
export async function grantUserPermission(
queue_id: string,
user_id: string,
right_name: string,
): Promise<{ data: UserPermission | null; error: string | null }> {
return request<UserPermission>("/user-permissions", {
method: "POST",
body: JSON.stringify({ queue_id, user_id, right_name }),
});
}
export async function revokeUserPermission(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/user-permissions/${id}`, { method: "DELETE" });
}
// Auth
function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("tessera_token");
}
export function setStoredToken(token: string | null) {
if (typeof window === "undefined") return;
if (token) {
localStorage.setItem("tessera_token", token);
} else {
localStorage.removeItem("tessera_token");
}
}
export async function login(
username: string,
password: string,
): Promise<{ data: LoginResult | null; error: string | null }> {
const result = await request<LoginResult>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
if (result.data?.token) {
setStoredToken(result.data.token);
}
return result;
}
export function logout() {
setStoredToken(null);
}
export async function getMe(): Promise<{ data: User | null; error: string | null }> {
const token = getStoredToken();
if (!token) return { data: null, error: "Not authenticated" };
try {
const res = await fetch(`/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
setStoredToken(null);
return { data: null, error: "Session expired" };
}
const data = await res.json();
return { data, error: null };
} catch (err) {
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
}
}
/**
* Fetch wrapper that includes the auth token.
*/
async function authRequest<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
const token = getStoredToken();
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
try {
const res = await fetch(`/api${url}`, { ...options, headers: { ...headers, ...(options?.headers as Record<string, string> ?? {}) } });
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
setStoredToken(null);
}
const body = await res.json().catch(() => ({ error: res.statusText }));
return { data: null, error: body.error || body.message || `HTTP ${res.status}` };
}
const data = await res.json();
return { data, error: null };
} catch (err) {
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
}
}