mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Fix frontend API calls missing auth tokens
Several frontend components were using raw fetch() instead of the API client, causing 401 Unauthorized errors when authentication is enabled. Changes: - SpoolmanSettings: Use api.getSpoolmanSettings/updateSpoolmanSettings - ProjectsPage: Use api.importProjectFile for ZIP imports - ProjectDetailPage: Use api.exportProjectZip for exports - CameraPage/EmbeddedCameraViewer: Use api.getCameraStatus Added to API client: - getSpoolmanSettings() - updateSpoolmanSettings() - importProjectFile() - exportProjectZip() - getCameraStatus() Added tests for auth token handling in client.test.ts Fixed VirtualPrinterSettings test expectations to match i18n strings Closes #231
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Tests for the API client auth token handling.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { setAuthToken, getAuthToken, api } from '../../api/client';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
store: {} as Record<string, string>,
|
||||
getItem: vi.fn((key: string) => localStorageMock.store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
localStorageMock.store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete localStorageMock.store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
localStorageMock.store = {};
|
||||
}),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
// Create MSW server
|
||||
const server = setupServer();
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
localStorageMock.clear();
|
||||
setAuthToken(null);
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe('Auth Token Management', () => {
|
||||
it('setAuthToken stores token in localStorage', () => {
|
||||
setAuthToken('test-token-123');
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'test-token-123');
|
||||
expect(getAuthToken()).toBe('test-token-123');
|
||||
});
|
||||
|
||||
it('setAuthToken removes token from localStorage when null', () => {
|
||||
setAuthToken('test-token-123');
|
||||
setAuthToken(null);
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
||||
expect(getAuthToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Client Auth Header', () => {
|
||||
it('includes Authorization header when token is set', async () => {
|
||||
let capturedHeaders: Headers | null = null;
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/settings/spoolman', ({ request }) => {
|
||||
capturedHeaders = request.headers;
|
||||
return HttpResponse.json({
|
||||
spoolman_enabled: 'false',
|
||||
spoolman_url: '',
|
||||
spoolman_sync_mode: 'auto',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
setAuthToken('test-jwt-token');
|
||||
await api.getSpoolmanSettings();
|
||||
|
||||
expect(capturedHeaders).not.toBeNull();
|
||||
expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-jwt-token');
|
||||
});
|
||||
|
||||
it('does not include Authorization header when token is not set', async () => {
|
||||
let capturedHeaders: Headers | null = null;
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/settings/spoolman', ({ request }) => {
|
||||
capturedHeaders = request.headers;
|
||||
return HttpResponse.json({
|
||||
spoolman_enabled: 'false',
|
||||
spoolman_url: '',
|
||||
spoolman_sync_mode: 'auto',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
setAuthToken(null);
|
||||
await api.getSpoolmanSettings();
|
||||
|
||||
expect(capturedHeaders).not.toBeNull();
|
||||
expect(capturedHeaders!.get('Authorization')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears token on 401 Unauthorized response', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/settings/spoolman', () => {
|
||||
return HttpResponse.json(
|
||||
{ detail: 'Not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
setAuthToken('expired-token');
|
||||
expect(getAuthToken()).toBe('expired-token');
|
||||
|
||||
try {
|
||||
await api.getSpoolmanSettings();
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(getAuthToken()).toBeNull();
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FormData requests include auth header', () => {
|
||||
it('importProjectFile includes Authorization header', async () => {
|
||||
let capturedHeaders: Headers | null = null;
|
||||
|
||||
server.use(
|
||||
http.post('/api/v1/projects/import/file', ({ request }) => {
|
||||
capturedHeaders = request.headers;
|
||||
return HttpResponse.json({
|
||||
id: 1,
|
||||
name: 'Test Project',
|
||||
description: '',
|
||||
total_cost: 0,
|
||||
total_print_time_seconds: 0,
|
||||
total_prints: 0,
|
||||
total_quantity: 0,
|
||||
status: 'active',
|
||||
due_date: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
archives: [],
|
||||
bom_items: [],
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
setAuthToken('test-token');
|
||||
const file = new File(['test content'], 'test.zip', { type: 'application/zip' });
|
||||
await api.importProjectFile(file);
|
||||
|
||||
expect(capturedHeaders).not.toBeNull();
|
||||
expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');
|
||||
});
|
||||
|
||||
it('exportProjectZip includes Authorization header', async () => {
|
||||
let capturedHeaders: Headers | null = null;
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/projects/:projectId/export', ({ request }) => {
|
||||
capturedHeaders = request.headers;
|
||||
const zipContent = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // ZIP magic bytes
|
||||
return new HttpResponse(zipContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': 'attachment; filename="project.zip"',
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
setAuthToken('test-token');
|
||||
await api.exportProjectZip(1);
|
||||
|
||||
expect(capturedHeaders).not.toBeNull();
|
||||
expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
getSpoolmanSettings: vi.fn(),
|
||||
updateSpoolmanSettings: vi.fn(),
|
||||
getSpoolmanStatus: vi.fn(),
|
||||
connectSpoolman: vi.fn(),
|
||||
disconnectSpoolman: vi.fn(),
|
||||
@@ -30,17 +32,21 @@ vi.mock('../../api/client', () => ({
|
||||
// Import mocked module
|
||||
import { api } from '../../api/client';
|
||||
|
||||
// Mock fetch for Spoolman settings endpoints
|
||||
const mockFetchResponse = (data: object) => ({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(data),
|
||||
});
|
||||
|
||||
describe('SpoolmanSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default API mocks
|
||||
vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
|
||||
spoolman_enabled: 'false',
|
||||
spoolman_url: '',
|
||||
spoolman_sync_mode: 'auto',
|
||||
});
|
||||
vi.mocked(api.updateSpoolmanSettings).mockResolvedValue({
|
||||
spoolman_enabled: 'false',
|
||||
spoolman_url: '',
|
||||
spoolman_sync_mode: 'auto',
|
||||
});
|
||||
vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
|
||||
enabled: false,
|
||||
connected: false,
|
||||
@@ -56,26 +62,12 @@ describe('SpoolmanSettings', () => {
|
||||
skipped: [],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
// Default fetch mock for settings - disabled state
|
||||
global.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('/api/v1/settings/spoolman')) {
|
||||
return Promise.resolve(
|
||||
mockFetchResponse({
|
||||
spoolman_enabled: 'false',
|
||||
spoolman_url: '',
|
||||
spoolman_sync_mode: 'auto',
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error('Unknown URL'));
|
||||
}) as any;
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders loading state initially', () => {
|
||||
// Delay the fetch response to catch loading state
|
||||
global.fetch = vi.fn().mockImplementation(() => new Promise(() => {})) as any;
|
||||
// Delay the API response to catch loading state
|
||||
vi.mocked(api.getSpoolmanSettings).mockImplementation(() => new Promise(() => {}));
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
// Should show loading spinner
|
||||
@@ -159,27 +151,16 @@ describe('SpoolmanSettings', () => {
|
||||
|
||||
describe('enabled state', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('/api/v1/settings/spoolman')) {
|
||||
if (url.includes('PUT') || (url as any).method === 'PUT') {
|
||||
return Promise.resolve(
|
||||
mockFetchResponse({
|
||||
spoolman_enabled: 'true',
|
||||
spoolman_url: 'http://localhost:7912',
|
||||
spoolman_sync_mode: 'auto',
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.resolve(
|
||||
mockFetchResponse({
|
||||
spoolman_enabled: 'true',
|
||||
spoolman_url: 'http://localhost:7912',
|
||||
spoolman_sync_mode: 'auto',
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error('Unknown URL'));
|
||||
}) as any;
|
||||
vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
|
||||
spoolman_enabled: 'true',
|
||||
spoolman_url: 'http://localhost:7912',
|
||||
spoolman_sync_mode: 'auto',
|
||||
});
|
||||
vi.mocked(api.updateSpoolmanSettings).mockResolvedValue({
|
||||
spoolman_enabled: 'true',
|
||||
spoolman_url: 'http://localhost:7912',
|
||||
spoolman_sync_mode: 'auto',
|
||||
});
|
||||
});
|
||||
|
||||
it('URL input is enabled when Spoolman is enabled', async () => {
|
||||
|
||||
@@ -274,7 +274,7 @@ describe('VirtualPrinterSettings', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Review')).toBeInTheDocument();
|
||||
expect(screen.getByText('Review and tag before archiving')).toBeInTheDocument();
|
||||
expect(screen.getByText('Review before archiving')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('VirtualPrinterSettings', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Queue')).toBeInTheDocument();
|
||||
expect(screen.getByText('Archive and add to print queue')).toBeInTheDocument();
|
||||
expect(screen.getByText('Archive and add to queue')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2969,6 +2969,13 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tray_uuid: trayUuid }),
|
||||
}),
|
||||
getSpoolmanSettings: () =>
|
||||
request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string }>('/settings/spoolman'),
|
||||
updateSpoolmanSettings: (data: { spoolman_enabled?: string; spoolman_url?: string; spoolman_sync_mode?: string }) =>
|
||||
request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string }>('/settings/spoolman', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Updates
|
||||
getVersion: () => request<VersionInfo>('/updates/version'),
|
||||
@@ -3030,6 +3037,8 @@ export const api = {
|
||||
`${API_BASE}/printers/${printerId}/camera/snapshot`,
|
||||
testCameraConnection: (printerId: number) =>
|
||||
request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
|
||||
getCameraStatus: (printerId: number) =>
|
||||
request<{ active: boolean; stalled: boolean }>(`/printers/${printerId}/camera/status`),
|
||||
|
||||
// Plate Detection - Multi-reference calibration (stores up to 5 references per printer)
|
||||
checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {
|
||||
@@ -3234,6 +3243,42 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
importProjectFile: async (file: File): Promise<Project> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const headers: Record<string, string> = {};
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
const response = await fetch(`${API_BASE}/projects/import/file`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
exportProjectZip: async (projectId: number): Promise<{ blob: Blob; filename: string }> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
const response = await fetch(`${API_BASE}/projects/${projectId}/export`, {
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
|
||||
const filename = filenameMatch?.[1] || `project_${projectId}.zip`;
|
||||
const blob = await response.blob();
|
||||
return { blob, filename };
|
||||
},
|
||||
|
||||
// API Keys
|
||||
getAPIKeys: () => request<APIKey[]>('/api-keys/'),
|
||||
|
||||
@@ -184,17 +184,14 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
|
||||
|
||||
stallCheckIntervalRef.current = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/printers/${printerId}/camera/status`);
|
||||
if (response.ok) {
|
||||
const status = await response.json();
|
||||
if (status.stalled || (!status.active && !streamError)) {
|
||||
if (stallCheckIntervalRef.current) {
|
||||
clearInterval(stallCheckIntervalRef.current);
|
||||
stallCheckIntervalRef.current = null;
|
||||
}
|
||||
setStreamLoading(false);
|
||||
attemptReconnect();
|
||||
const status = await api.getCameraStatus(printerId);
|
||||
if (status.stalled || (!status.active && !streamError)) {
|
||||
if (stallCheckIntervalRef.current) {
|
||||
clearInterval(stallCheckIntervalRef.current);
|
||||
stallCheckIntervalRef.current = null;
|
||||
}
|
||||
setStreamLoading(false);
|
||||
attemptReconnect();
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
|
||||
@@ -6,32 +6,6 @@ import type { SpoolmanSyncResult, Printer } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from './Card';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface SpoolmanSettingsData {
|
||||
spoolman_enabled: string;
|
||||
spoolman_url: string;
|
||||
spoolman_sync_mode: string;
|
||||
}
|
||||
|
||||
async function getSpoolmanSettings(): Promise<SpoolmanSettingsData> {
|
||||
const response = await fetch('/api/v1/settings/spoolman');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load Spoolman settings');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function updateSpoolmanSettings(data: Partial<SpoolmanSettingsData>): Promise<SpoolmanSettingsData> {
|
||||
const response = await fetch('/api/v1/settings/spoolman', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save Spoolman settings');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function SpoolmanSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const [localEnabled, setLocalEnabled] = useState(false);
|
||||
@@ -45,7 +19,7 @@ export function SpoolmanSettings() {
|
||||
// Fetch Spoolman settings
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ['spoolman-settings'],
|
||||
queryFn: getSpoolmanSettings,
|
||||
queryFn: api.getSpoolmanSettings,
|
||||
});
|
||||
|
||||
// Fetch Spoolman status
|
||||
@@ -93,7 +67,7 @@ export function SpoolmanSettings() {
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateSpoolmanSettings({
|
||||
api.updateSpoolmanSettings({
|
||||
spoolman_enabled: localEnabled ? 'true' : 'false',
|
||||
spoolman_url: localUrl,
|
||||
spoolman_sync_mode: localSyncMode,
|
||||
|
||||
@@ -224,21 +224,18 @@ export function CameraPage() {
|
||||
// Start stall detection after stream has loaded
|
||||
stallCheckIntervalRef.current = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/printers/${id}/camera/status`);
|
||||
if (response.ok) {
|
||||
const status = await response.json();
|
||||
// Trigger reconnect if:
|
||||
// 1. Backend reports stall (no frames for 10+ seconds)
|
||||
// 2. OR stream is not active anymore (process died)
|
||||
if (status.stalled || (!status.active && !streamError)) {
|
||||
console.log(`Stream issue detected: stalled=${status.stalled}, active=${status.active}, reconnecting...`);
|
||||
if (stallCheckIntervalRef.current) {
|
||||
clearInterval(stallCheckIntervalRef.current);
|
||||
stallCheckIntervalRef.current = null;
|
||||
}
|
||||
setStreamLoading(false);
|
||||
attemptReconnect();
|
||||
const status = await api.getCameraStatus(id);
|
||||
// Trigger reconnect if:
|
||||
// 1. Backend reports stall (no frames for 10+ seconds)
|
||||
// 2. OR stream is not active anymore (process died)
|
||||
if (status.stalled || (!status.active && !streamError)) {
|
||||
console.log(`Stream issue detected: stalled=${status.stalled}, active=${status.active}, reconnecting...`);
|
||||
if (stallCheckIntervalRef.current) {
|
||||
clearInterval(stallCheckIntervalRef.current);
|
||||
stallCheckIntervalRef.current = null;
|
||||
}
|
||||
setStreamLoading(false);
|
||||
attemptReconnect();
|
||||
}
|
||||
} catch {
|
||||
// Ignore fetch errors - server might be temporarily unavailable
|
||||
|
||||
@@ -404,19 +404,11 @@ export function ProjectDetailPage() {
|
||||
|
||||
const handleExportProject = async () => {
|
||||
try {
|
||||
// Fetch ZIP file directly
|
||||
const response = await fetch(`/api/v1/projects/${projectId}/export`);
|
||||
if (!response.ok) {
|
||||
throw new Error(t('projectDetail.toast.exportFailed'));
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const { blob, filename } = await api.exportProjectZip(Number(projectId));
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
// Get filename from Content-Disposition header or use default
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
|
||||
a.download = filenameMatch?.[1] || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`;
|
||||
a.download = filename || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(t('projectDetail.toast.projectExported'), 'success');
|
||||
|
||||
@@ -681,19 +681,7 @@ export function ProjectsPage() {
|
||||
|
||||
if (filename.endsWith('.zip')) {
|
||||
// ZIP file: upload via file endpoint
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/v1/projects/import/file', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Import failed');
|
||||
}
|
||||
|
||||
await api.importProjectFile(file);
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
showToast(t('projects.toast.imported'), 'success');
|
||||
} else {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-DOAYfAw5.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-C_9mu6sD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DME4t7XG.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user