This commit is contained in:
Dmytro Tkachenko
2026-08-29 11:59:28 +03:00
commit 28d817ebe9
147 changed files with 17534 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
import { apiGet, apiPost, apiFetch } from './api.service';
import type { Role } from './auth.service';
export interface AppUser { id: number; username: string; role: Role; createdAt: string | null; lastLoginAt: string | null; }
export interface TokenInfo { id: number; tokenId: string; label: string; createdAt: string | null; lastUsedAt: string | null; expiresAt: string | null; revoked: boolean; }
export async function listUsers(): Promise<AppUser[]> {
return (await apiGet<{ users: AppUser[] }>('/api/users')).users;
}
export function createUser(username: string, password: string, role: Role): Promise<{ user?: AppUser; error?: string; message?: string }> {
return apiPost('/api/users', { username, password, role }, { allowAuthErrorBody: true });
}
export function deleteUser(username: string): Promise<{ success: boolean }> {
return apiFetch(`/api/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
}
export async function listTokens(): Promise<TokenInfo[]> {
return (await apiGet<{ tokens: TokenInfo[] }>('/api/tokens')).tokens;
}
export function createToken(label: string): Promise<{ token: string; label: string }> {
return apiPost('/api/tokens', { label });
}
export function revokeToken(id: number): Promise<{ success: boolean }> {
return apiPost(`/api/tokens/${id}/revoke`, {});
}
+22
View File
@@ -0,0 +1,22 @@
import { apiGet } from './api.service';
import type { OverviewResponse, SlaMetric, ForgeConfig, InsightsResponse, JiraDurationsResponse } from '../types/analytics.types';
export function getInsights(): Promise<InsightsResponse> {
return apiGet<InsightsResponse>('/api/insights');
}
export function getJiraDurations(): Promise<JiraDurationsResponse> {
return apiGet<JiraDurationsResponse>('/api/analytics/jira-durations');
}
export function getOverview(): Promise<OverviewResponse> {
return apiGet<OverviewResponse>('/api/analytics/overview');
}
export function getSla(): Promise<{ metrics: SlaMetric[] }> {
return apiGet<{ metrics: SlaMetric[] }>('/api/analytics/sla');
}
export function getConfig(): Promise<ForgeConfig> {
return apiGet<ForgeConfig>('/api/config');
}
+42
View File
@@ -0,0 +1,42 @@
// Fired when any API call returns 401 (session expired/absent). App registers it
// to drop to the login screen.
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: (() => void) | null): void {
onUnauthorized = fn;
}
interface ApiOptions extends RequestInit {
// When true, a 401 body is returned to the caller instead of throwing +
// firing the logout hook. Used by /login to surface { success, error }.
allowAuthErrorBody?: boolean;
}
export async function apiFetch<T>(url: string, options?: ApiOptions): Promise<T> {
const { allowAuthErrorBody, ...init } = options ?? {};
const res = await fetch(url, init);
if (!res.ok) {
if (res.status === 401) {
if (!allowAuthErrorBody) { onUnauthorized?.(); throw new Error('unauthenticated'); }
// else fall through and return the { success, error } body
} else if (res.status === 400 || res.status === 409) {
// Validation / conflict responses carry a { error, message } body the caller reads.
} else {
const text = await res.text().catch(() => res.statusText);
throw new Error(text || `HTTP ${res.status}`);
}
}
return res.json() as Promise<T>;
}
export function apiGet<T>(url: string): Promise<T> {
return apiFetch<T>(url);
}
export function apiPost<T>(url: string, body: unknown, options?: ApiOptions): Promise<T> {
return apiFetch<T>(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
...options,
});
}
+29
View File
@@ -0,0 +1,29 @@
import { apiGet, apiPost } from './api.service';
export type Role = 'viewer' | 'pm' | 'lead' | 'admin';
export interface CurrentUser {
username: string;
role: Role;
}
const ORDER: Role[] = ['viewer', 'pm', 'lead', 'admin'];
export function roleAtLeast(role: Role | undefined, min: Role): boolean {
return !!role && ORDER.indexOf(role) >= ORDER.indexOf(min);
}
export const ROLE_LABELS: Record<Role, string> = {
viewer: 'Viewer', pm: 'PM', lead: 'Project Leadership', admin: 'Admin',
};
export async function getMe(): Promise<CurrentUser | null> {
const data = await apiGet<{ user: CurrentUser | null }>('/api/me');
return data.user;
}
export function login(username: string, password: string): Promise<{ success: boolean; user?: CurrentUser; error?: string }> {
return apiPost('/login', { username, password }, { allowAuthErrorBody: true });
}
export function logout(): Promise<{ success: boolean }> {
return apiPost('/logout', {});
}
+17
View File
@@ -0,0 +1,17 @@
import { apiGet } from './api.service';
import type { Ticket, TicketFilters, StatsResponse } from '../types/ticket.types';
export async function getTickets(filters: TicketFilters = {}): Promise<Ticket[]> {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(filters)) if (v) qs.set(k, String(v));
const data = await apiGet<{ tickets: Ticket[] }>(`/api/tickets?${qs.toString()}`);
return data.tickets;
}
export function getTicket(number: string): Promise<Ticket> {
return apiGet<Ticket>(`/api/tickets/${encodeURIComponent(number)}`);
}
export function getStats(): Promise<StatsResponse> {
return apiGet<StatsResponse>('/api/stats');
}