feat: Frontend gap closure — Schedules, Alerts, Migration, Changelog views

Implements missing frontend views and API integrations:

New Views:
- SchedulesView: CRUD for scheduled tasks (restart/announcement/command/plugin_reload)
- MigrationView: Export/import interface with file upload and history tracking
- ChangelogView: Paginated changelog feed with category badges
- ForgotPasswordView: Password reset flow with email submission
- AlertsView: Alert config dashboard with threshold settings and history

Component Updates:
- ErrorBoundary: Global error handler with retry functionality
- DashboardLayout: Mobile responsive sidebar, permission-based nav, new menu items
- ServerInfoView: Complete rewrite for public server info display

Infrastructure:
- useApi: Token refresh interceptor with 401 retry and infinite loop prevention
- plugins store: Implemented all stubbed methods with real API calls
- auth store: Added hasPermission() helper for RBAC UI visibility
- Router: Added new routes with catch-all fallback

Purpose: Closes frontend implementation gaps. Hardens auth flow, improves mobile UX,
enables server automation scheduling, alert configuration, and data migration tools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-15 21:20:40 -05:00
parent 8cd792eb75
commit 4c648783a2
14 changed files with 1327 additions and 40 deletions

View File

@@ -8,6 +8,9 @@ interface RequestOptions {
headers?: Record<string, string>
}
let isRefreshing = false
let refreshPromise: Promise<void> | null = null
/**
* Composable for making authenticated API requests.
* Automatically attaches JWT token and handles token refresh.
@@ -15,6 +18,43 @@ interface RequestOptions {
export function useApi() {
const auth = useAuthStore()
async function attemptRefresh(): Promise<void> {
if (isRefreshing && refreshPromise) {
return refreshPromise
}
isRefreshing = true
refreshPromise = (async () => {
try {
const response = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: auth.refreshToken }),
})
if (!response.ok) {
throw new Error('Refresh failed')
}
const data = await response.json()
auth.setAuth({
access_token: data.access_token,
refresh_token: data.refresh_token,
user: auth.user!,
})
} catch {
auth.logout()
window.location.href = '/login'
throw new Error('Session expired')
} finally {
isRefreshing = false
refreshPromise = null
}
})()
return refreshPromise
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -25,17 +65,22 @@ export function useApi() {
headers['Authorization'] = `Bearer ${auth.accessToken}`
}
const response = await fetch(`${API_BASE}${path}`, {
let response = await fetch(`${API_BASE}${path}`, {
method: options.method || 'GET',
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
})
if (response.status === 401) {
// TODO: Attempt token refresh, retry, or redirect to login
auth.logout()
window.location.href = '/login'
throw new Error('Unauthorized')
if (response.status === 401 && auth.refreshToken) {
await attemptRefresh()
// Retry original request with new token
headers['Authorization'] = `Bearer ${auth.accessToken}`
response = await fetch(`${API_BASE}${path}`, {
method: options.method || 'GET',
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
})
}
if (!response.ok) {