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:
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user