Users can now attach images in the web chat for Simba to analyze using Ollama's gemma3 vision model. Images are stored in Garage (S3-compatible) and displayed in chat history. Also fixes aerich migration config by extracting TORTOISE_CONFIG into a standalone config/db.py module, removing the stale aerich_config.py, and adding missing MODELS_STATE to migration 3. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
202 lines
5.8 KiB
TypeScript
202 lines
5.8 KiB
TypeScript
interface LoginResponse {
|
|
access_token: string;
|
|
refresh_token: string;
|
|
user: {
|
|
id: string;
|
|
username: string;
|
|
email?: string;
|
|
};
|
|
}
|
|
|
|
interface RefreshResponse {
|
|
access_token: string;
|
|
}
|
|
|
|
class UserService {
|
|
private baseUrl = "/api/user";
|
|
|
|
async login(username: string, password: string): Promise<LoginResponse> {
|
|
const response = await fetch(`${this.baseUrl}/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Invalid credentials");
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
async refreshToken(): Promise<string> {
|
|
const refreshToken = localStorage.getItem("refresh_token");
|
|
|
|
if (!refreshToken) {
|
|
throw new Error("No refresh token available");
|
|
}
|
|
|
|
const response = await fetch(`${this.baseUrl}/refresh`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${refreshToken}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// Refresh token is invalid or expired, clear storage
|
|
localStorage.removeItem("access_token");
|
|
localStorage.removeItem("refresh_token");
|
|
throw new Error("Failed to refresh token");
|
|
}
|
|
|
|
const data: RefreshResponse = await response.json();
|
|
localStorage.setItem("access_token", data.access_token);
|
|
return data.access_token;
|
|
}
|
|
|
|
async validateToken(): Promise<boolean> {
|
|
const refreshToken = localStorage.getItem("refresh_token");
|
|
|
|
if (!refreshToken) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await this.refreshToken();
|
|
return true;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async fetchWithAuth(
|
|
url: string,
|
|
options: RequestInit = {},
|
|
): Promise<Response> {
|
|
const accessToken = localStorage.getItem("access_token");
|
|
|
|
// Add authorization header
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
...(options.headers || {}),
|
|
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
|
|
};
|
|
|
|
let response = await fetch(url, { ...options, headers });
|
|
|
|
// If unauthorized, try refreshing the token
|
|
if (response.status === 401) {
|
|
try {
|
|
const newAccessToken = await this.refreshToken();
|
|
|
|
// Retry the request with new token
|
|
headers.Authorization = `Bearer ${newAccessToken}`;
|
|
response = await fetch(url, { ...options, headers });
|
|
} catch (error) {
|
|
// Refresh failed, redirect to login or throw error
|
|
throw new Error("Session expired. Please log in again.");
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
async fetchWithRefreshToken(
|
|
url: string,
|
|
options: RequestInit = {},
|
|
{ skipContentType = false }: { skipContentType?: boolean } = {},
|
|
): Promise<Response> {
|
|
const refreshToken = localStorage.getItem("refresh_token");
|
|
|
|
// Add authorization header
|
|
const headers: Record<string, string> = {
|
|
...(skipContentType ? {} : { "Content-Type": "application/json" }),
|
|
...((options.headers as Record<string, string>) || {}),
|
|
...(refreshToken ? { Authorization: `Bearer ${refreshToken}` } : {}),
|
|
};
|
|
|
|
let response = await fetch(url, { ...options, headers });
|
|
|
|
// If unauthorized, try refreshing the token
|
|
if (response.status === 401) {
|
|
try {
|
|
const newAccessToken = await this.refreshToken();
|
|
|
|
// Retry the request with new token
|
|
headers.Authorization = `Bearer ${newAccessToken}`;
|
|
response = await fetch(url, { ...options, headers });
|
|
} catch (error) {
|
|
// Refresh failed, redirect to login or throw error
|
|
throw new Error("Session expired. Please log in again.");
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
async getMe(): Promise<{ id: string; username: string; email: string; is_admin: boolean }> {
|
|
const response = await this.fetchWithRefreshToken(`${this.baseUrl}/me`);
|
|
if (!response.ok) throw new Error("Failed to fetch user profile");
|
|
return response.json();
|
|
}
|
|
|
|
async adminListUsers(): Promise<AdminUserRecord[]> {
|
|
const response = await this.fetchWithRefreshToken(`${this.baseUrl}/admin/users`);
|
|
if (!response.ok) throw new Error("Failed to list users");
|
|
return response.json();
|
|
}
|
|
|
|
async adminSetWhatsapp(userId: string, number: string): Promise<AdminUserRecord> {
|
|
const response = await this.fetchWithRefreshToken(
|
|
`${this.baseUrl}/admin/users/${userId}/whatsapp`,
|
|
{ method: "PUT", body: JSON.stringify({ whatsapp_number: number }) },
|
|
);
|
|
if (response.status === 409) {
|
|
const data = await response.json();
|
|
throw new Error(data.error ?? "WhatsApp number already in use");
|
|
}
|
|
if (!response.ok) throw new Error("Failed to set WhatsApp number");
|
|
return response.json();
|
|
}
|
|
|
|
async adminUnlinkWhatsapp(userId: string): Promise<void> {
|
|
const response = await this.fetchWithRefreshToken(
|
|
`${this.baseUrl}/admin/users/${userId}/whatsapp`,
|
|
{ method: "DELETE" },
|
|
);
|
|
if (!response.ok) throw new Error("Failed to unlink WhatsApp number");
|
|
}
|
|
|
|
async adminToggleEmail(userId: string): Promise<AdminUserRecord> {
|
|
const response = await this.fetchWithRefreshToken(
|
|
`${this.baseUrl}/admin/users/${userId}/email`,
|
|
{ method: "PUT" },
|
|
);
|
|
if (!response.ok) throw new Error("Failed to enable email");
|
|
return response.json();
|
|
}
|
|
|
|
async adminDisableEmail(userId: string): Promise<void> {
|
|
const response = await this.fetchWithRefreshToken(
|
|
`${this.baseUrl}/admin/users/${userId}/email`,
|
|
{ method: "DELETE" },
|
|
);
|
|
if (!response.ok) throw new Error("Failed to disable email");
|
|
}
|
|
}
|
|
|
|
export interface AdminUserRecord {
|
|
id: string;
|
|
username: string;
|
|
email: string;
|
|
whatsapp_number: string | null;
|
|
auth_provider: string;
|
|
email_enabled: boolean;
|
|
email_address: string | null;
|
|
}
|
|
|
|
export { UserService };
|
|
export const userService = new UserService();
|