{t('admin.resetDialogTitle')}
-
{t('admin.resetDialogDescription')}
+
{credentialModal.description}
diff --git a/webui/src/pages/AuditLogs/index.tsx b/webui/src/pages/AuditLogs/index.tsx
new file mode 100644
index 000000000..7fe0e0436
--- /dev/null
+++ b/webui/src/pages/AuditLogs/index.tsx
@@ -0,0 +1,569 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Download, Loader2, ShieldCheck } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+import PageHeader from '@/components/common/PageHeader';
+import { useAuth } from '@/contexts/AuthContext';
+import { flocksproAuditApi, type AuditEventItem } from '@/api/flocksproAudit';
+import { flocksproUsersApi } from '@/api/flocksproUsers';
+
+const PAGE_SIZE = 20;
+const EXPORT_PAGE_SIZE = 500;
+
+interface AuditFilters {
+ eventType: string;
+ actorId: string;
+ result: string;
+ startAt: string;
+ endAt: string;
+}
+
+const EMPTY_FILTERS: AuditFilters = {
+ eventType: '',
+ actorId: '',
+ result: '',
+ startAt: '',
+ endAt: '',
+};
+
+function toLocalTimestampOrEmpty(value: string): string | undefined {
+ if (!value) return undefined;
+ return value.length === 16 ? `${value}:00` : value;
+}
+
+function formatLocalTime(value: string): string {
+ if (!value) return '-';
+ const normalized = value.includes('T') ? value : value.replace(' ', 'T');
+ const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/.test(normalized);
+ const parsed = new Date(hasTimezone ? normalized : `${normalized}Z`);
+ if (Number.isNaN(parsed.getTime())) return value;
+ const year = parsed.getFullYear();
+ const month = String(parsed.getMonth() + 1).padStart(2, '0');
+ const day = String(parsed.getDate()).padStart(2, '0');
+ const hour = String(parsed.getHours()).padStart(2, '0');
+ const minute = String(parsed.getMinutes()).padStart(2, '0');
+ const second = String(parsed.getSeconds()).padStart(2, '0');
+ return `${year}/${month}/${day} ${hour}:${minute}:${second}`;
+}
+
+function payloadPreview(item: AuditEventItem): string {
+ const data = item.payload ?? item.metadata ?? {};
+ const serialized = JSON.stringify(data, null, 2);
+ if (!serialized || serialized === '{}') return '-';
+ return serialized.length > 260 ? `${serialized.slice(0, 257)}...` : serialized;
+}
+
+function payloadFullText(item: AuditEventItem): string {
+ const data = item.payload ?? item.metadata ?? {};
+ const serialized = JSON.stringify(data, null, 2);
+ return !serialized || serialized === '{}' ? '-' : serialized;
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function exportFilename(): string {
+ const stamp = new Date()
+ .toLocaleString('sv-SE', { hour12: false })
+ .replace(/[^\d]/g, '')
+ .slice(0, 14);
+ return `audit_logs_${stamp}.xls`;
+}
+
+function parseAuditTime(value: string): number {
+ if (!value) return 0;
+ const valueTrimmed = value.trim();
+ const mmdd = valueTrimmed.match(
+ /^(\d{2})\/(\d{2})\/(\d{4})(?:,\s*|\s+)(\d{2}):(\d{2}):(\d{2})$/,
+ );
+ if (mmdd) {
+ const [, month, day, year, hour, minute, second] = mmdd;
+ return new Date(
+ Number(year),
+ Number(month) - 1,
+ Number(day),
+ Number(hour),
+ Number(minute),
+ Number(second),
+ ).getTime();
+ }
+ const ymd = valueTrimmed.match(
+ /^(\d{4})\/(\d{2})\/(\d{2})(?:,\s*|\s+)(\d{2}):(\d{2}):(\d{2})$/,
+ );
+ if (ymd) {
+ const [, year, month, day, hour, minute, second] = ymd;
+ return new Date(
+ Number(year),
+ Number(month) - 1,
+ Number(day),
+ Number(hour),
+ Number(minute),
+ Number(second),
+ ).getTime();
+ }
+ const normalized = valueTrimmed.includes('T') ? valueTrimmed : valueTrimmed.replace(' ', 'T');
+ const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/.test(normalized);
+ const parsed = new Date(hasTimezone ? normalized : `${normalized}Z`);
+ return Number.isNaN(parsed.getTime()) ? 0 : parsed.getTime();
+}
+
+function sortByCreatedAtDesc(items: AuditEventItem[]): AuditEventItem[] {
+ return [...items].sort((a, b) => {
+ const delta = parseAuditTime(b.created_at) - parseAuditTime(a.created_at);
+ if (delta !== 0) return delta;
+ return (b.id ?? 0) - (a.id ?? 0);
+ });
+}
+
+function stringFromPayload(item: AuditEventItem, keys: string[]): string | undefined {
+ const data = item.payload ?? item.metadata ?? {};
+ for (const key of keys) {
+ const value = data[key];
+ if (typeof value === 'string' && value.trim()) {
+ return value;
+ }
+ }
+ return undefined;
+}
+
+function actorLabel(item: AuditEventItem): string {
+ return (
+ item.user_name
+ || item.actor_name
+ || stringFromPayload(item, ['user_name', 'username', 'actor_name', 'actor_id'])
+ || item.user_id
+ || item.actor_id
+ || '-'
+ );
+}
+
+function buildAuditQuery(filters: AuditFilters) {
+ return {
+ event_type: filters.eventType || undefined,
+ username: filters.actorId || undefined,
+ result: filters.result || undefined,
+ start_at: toLocalTimestampOrEmpty(filters.startAt),
+ end_at: toLocalTimestampOrEmpty(filters.endAt),
+ sort_by: 'created_at',
+ order: 'desc' as const,
+ };
+}
+
+export default function AuditLogsPage() {
+ const { t } = useTranslation('flockspro');
+ const { user } = useAuth();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState
(null);
+ const [items, setItems] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [offset, setOffset] = useState(0);
+ const [eventType, setEventType] = useState('');
+ const [actorId, setActorId] = useState('');
+ const [result, setResult] = useState('');
+ const [startAt, setStartAt] = useState('');
+ const [endAt, setEndAt] = useState('');
+ const [expandedRowId, setExpandedRowId] = useState(null);
+ const [exporting, setExporting] = useState(false);
+ const [eventTypeOptions, setEventTypeOptions] = useState([]);
+ const [checkingCapability, setCheckingCapability] = useState(true);
+ const [hasFlocksproCapability, setHasFlocksproCapability] = useState(false);
+
+ const page = useMemo(() => Math.floor(offset / PAGE_SIZE) + 1, [offset]);
+ const pageCount = useMemo(() => Math.max(1, Math.ceil(total / PAGE_SIZE)), [total]);
+ const currentFilters: AuditFilters = {
+ eventType,
+ actorId,
+ result,
+ startAt,
+ endAt,
+ };
+ const load = async (nextOffset: number, filters: AuditFilters = currentFilters) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const query = buildAuditQuery(filters);
+ const response = await flocksproAuditApi.listEvents({
+ ...query,
+ limit: PAGE_SIZE,
+ offset: nextOffset,
+ });
+ setItems(sortByCreatedAtDesc(response.items));
+ setTotal(response.total ?? response.count ?? response.items.length);
+ setOffset(nextOffset);
+ } catch (err: any) {
+ const code = err?.response?.status;
+ if (code === 403) {
+ setError(t('audit.errors.forbidden'));
+ } else {
+ setError(err?.response?.data?.message || err?.message || t('audit.errors.fetch'));
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const exportToExcel = async () => {
+ setExporting(true);
+ setError(null);
+ try {
+ const query = buildAuditQuery(currentFilters);
+ const allItems: AuditEventItem[] = [];
+ let nextOffset = 0;
+ let expectedTotal: number | null = null;
+ while (expectedTotal === null || allItems.length < expectedTotal) {
+ const response = await flocksproAuditApi.listEvents({
+ ...query,
+ limit: EXPORT_PAGE_SIZE,
+ offset: nextOffset,
+ });
+ allItems.push(...response.items);
+ expectedTotal = response.total ?? response.count ?? allItems.length;
+ if (response.items.length === 0 || response.items.length < EXPORT_PAGE_SIZE) break;
+ nextOffset += response.items.length;
+ }
+ const sortedItems = sortByCreatedAtDesc(allItems);
+
+ const headers = [
+ t('audit.table.time'),
+ t('audit.table.eventType'),
+ t('audit.table.actor'),
+ t('audit.table.resource'),
+ t('audit.table.result'),
+ t('audit.table.payload'),
+ ];
+ const rows = sortedItems.map((item) => {
+ const resource = item.resource_type ? `${item.resource_type}:${item.resource_id || '-'}` : '-';
+ return [
+ formatLocalTime(item.created_at),
+ item.event_type,
+ actorLabel(item),
+ resource,
+ item.result || item.status || '-',
+ payloadFullText(item),
+ ];
+ });
+ const html = `
+
+
+
+
+
+
+
+ ${headers.map((header) => `| ${escapeHtml(header)} | `).join('')}
+
+ ${rows.map((row) => `${row.map((cell) => `${escapeHtml(String(cell)).replace(/\n/g, ' ')} | `).join('')}
`).join('')}
+
+
+
+`;
+ const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = exportFilename();
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+ } catch (err: any) {
+ setError(err?.response?.data?.message || err?.message || t('audit.errors.export'));
+ } finally {
+ setExporting(false);
+ }
+ };
+
+ useEffect(() => {
+ let cancelled = false;
+ setItems([]);
+ setTotal(0);
+ setOffset(0);
+ setEventTypeOptions([]);
+ setHasFlocksproCapability(false);
+ if (user?.role !== 'admin') {
+ setCheckingCapability(false);
+ return () => {
+ cancelled = true;
+ };
+ }
+
+ setCheckingCapability(true);
+ void flocksproUsersApi.hasCapability()
+ .then((ok) => {
+ if (cancelled) return;
+ setHasFlocksproCapability(ok);
+ if (!ok) {
+ setError(t('audit.errors.unavailable'));
+ }
+ })
+ .catch(() => {
+ if (cancelled) return;
+ setHasFlocksproCapability(false);
+ setError(t('audit.errors.unavailable'));
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setCheckingCapability(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [t, user?.role]);
+
+ useEffect(() => {
+ if (!hasFlocksproCapability) return;
+ void load(0);
+ }, [hasFlocksproCapability]);
+
+ useEffect(() => {
+ if (!hasFlocksproCapability) return;
+ let cancelled = false;
+ void flocksproAuditApi.listEventTypes()
+ .then((types) => {
+ if (!cancelled) {
+ setEventTypeOptions(types);
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setEventTypeOptions([]);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [hasFlocksproCapability]);
+
+ if (user?.role !== 'admin') {
+ return (
+
+
{t('audit.errors.forbidden')}
+
+ );
+ }
+
+ if (checkingCapability) {
+ return (
+
+ );
+ }
+
+ if (!hasFlocksproCapability) {
+ return (
+
+
{t('audit.errors.unavailable')}
+
+ );
+ }
+
+ return (
+
+
}
+ />
+
+
+
+
+ setActorId(e.target.value)}
+ placeholder={t('audit.filters.actor')}
+ className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
+ />
+
+ setStartAt(e.target.value)}
+ className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
+ />
+ setEndAt(e.target.value)}
+ className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
+ />
+
+
+
+
void load(0)}
+ disabled={loading}
+ className="rounded-lg bg-slate-900 text-white px-4 py-2 text-sm hover:bg-slate-800 disabled:opacity-50"
+ >
+ {t('audit.actions.search')}
+
+
{
+ setEventType('');
+ setActorId('');
+ setResult('');
+ setStartAt('');
+ setEndAt('');
+ void load(0, EMPTY_FILTERS);
+ }}
+ disabled={loading}
+ className="rounded-lg border border-gray-300 px-4 py-2 text-sm hover:bg-gray-50 disabled:opacity-50"
+ >
+ {t('audit.actions.reset')}
+
+
+
+ {t('audit.total', { total })}
+
+ void exportToExcel()}
+ disabled={loading || exporting}
+ title={exporting ? t('audit.actions.exporting') : t('audit.actions.exportExcel')}
+ aria-label={exporting ? t('audit.actions.exporting') : t('audit.actions.exportExcel')}
+ className="rounded-md border border-gray-300 p-2 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
+ >
+ {exporting ? : }
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | {t('audit.table.time')} |
+ {t('audit.table.eventType')} |
+ {t('audit.table.actor')} |
+ {t('audit.table.resource')} |
+ {t('audit.table.result')} |
+ {t('audit.table.payload')} |
+
+
+
+ {items.length === 0 && (
+
+ |
+ {loading ? t('audit.loading') : t('audit.empty')}
+ |
+
+ )}
+ {items.map((item) => {
+ const expanded = expandedRowId === item.id;
+ const actor = actorLabel(item);
+ const resource = item.resource_type ? `${item.resource_type}:${item.resource_id || '-'}` : '-';
+ const payload = payloadPreview(item);
+
+ return (
+ setExpandedRowId(expanded ? null : item.id)}
+ className="border-b border-gray-100 align-top cursor-pointer hover:bg-slate-50"
+ >
+ | {formatLocalTime(item.created_at)} |
+
+ {item.event_type}
+ |
+
+ {actor}
+ |
+
+ {resource}
+ |
+ {item.result || item.status} |
+
+ {payload}
+ |
+
+ );
+ })}
+
+
+
+
+
+
{t('audit.page', { page, pageCount })}
+
+ void load(Math.max(0, offset - PAGE_SIZE))}
+ disabled={loading || offset <= 0}
+ className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50 disabled:opacity-50"
+ >
+ {t('audit.actions.prev')}
+
+ void load(offset + PAGE_SIZE)}
+ disabled={loading || offset + PAGE_SIZE >= total}
+ className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50 disabled:opacity-50"
+ >
+ {t('audit.actions.next')}
+
+
+
+
+
+ );
+}
diff --git a/webui/src/pages/FlocksproUpgrade/Callback.tsx b/webui/src/pages/FlocksproUpgrade/Callback.tsx
new file mode 100644
index 000000000..035e0cd45
--- /dev/null
+++ b/webui/src/pages/FlocksproUpgrade/Callback.tsx
@@ -0,0 +1,66 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { Loader2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { authApi } from '@/api/auth';
+import { extractErrorMessage } from '@/utils/error';
+
+export default function FlocksproUpgradeCallbackPage() {
+ const { t } = useTranslation('flockspro');
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const consoleLoginId = searchParams.get('console_login_id');
+ const state = searchParams.get('state') ?? undefined;
+ const passportUid = searchParams.get('passport_uid') ?? undefined;
+ const loginStatus = searchParams.get('console_login_status');
+ const callbackMessage = searchParams.get('message') || searchParams.get('error_code');
+ if (loginStatus === 'error') {
+ setError(callbackMessage || t('callback.exchangeFailed'));
+ return;
+ }
+ if (!consoleLoginId) {
+ setError(t('callback.missingConsoleLoginId'));
+ return;
+ }
+
+ let cancelled = false;
+ const run = async () => {
+ try {
+ await authApi.finishConsoleLogin(consoleLoginId, state, passportUid);
+ if (!cancelled) {
+ navigate('/flockspro-upgrade?login=success', { replace: true });
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setError(extractErrorMessage(err, t('callback.exchangeFailed')));
+ }
+ }
+ };
+ void run();
+ return () => {
+ cancelled = true;
+ };
+ }, [navigate, searchParams, t]);
+
+ if (error) {
+ return (
+
+
{t('callback.failedTitle')}
+
{error}
+
+ );
+ }
+
+ return (
+
+
+
+ {t('callback.processing')}
+
+
+ );
+}
+
diff --git a/webui/src/pages/FlocksproUpgrade/index.tsx b/webui/src/pages/FlocksproUpgrade/index.tsx
new file mode 100644
index 000000000..789b76cc0
--- /dev/null
+++ b/webui/src/pages/FlocksproUpgrade/index.tsx
@@ -0,0 +1,1464 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { ArrowUpCircle, CheckCircle, ChevronDown, Loader2, LogIn, X, XCircle } from 'lucide-react';
+import { useSearchParams } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import PageHeader from '@/components/common/PageHeader';
+import { authApi, type ConsoleLoginSessionStatus } from '@/api/auth';
+import client from '@/api/client';
+import {
+ consoleUpgradeApi,
+ type ProPackageStatus,
+ type UpgradeRequestCreatePayload,
+ type UpgradeRequestStatus,
+} from '@/api/consoleUpgrade';
+import { type UpdateProgress } from '@/api/update';
+import { extractErrorMessage } from '@/utils/error';
+
+interface UpgradeApplyFormState {
+ product: string;
+ licenseType: 'poc' | 'commercial';
+ company: string;
+ applicantName: string;
+ salesRepName: string;
+ applicantEmail: string;
+ applicantPhone: string;
+ notes: string;
+}
+
+interface FlocksproLicenseStatus {
+ activated: boolean;
+ active: boolean;
+ license_id?: string | null;
+ status?: string | null;
+ license_status?: string | null;
+ inactive_reason?: string | null;
+ reapply_allowed?: boolean | null;
+ expires_at?: number | string | null;
+ last_sync_at?: number | string | null;
+ last_heartbeat_ok_at?: number | string | null;
+ max_admins?: number | null;
+ max_members?: number | null;
+ fingerprint?: string | null;
+ install_id?: string | null;
+ [key: string]: string | number | boolean | null | undefined;
+}
+
+const DEFAULT_FORM: UpgradeApplyFormState = {
+ product: 'Flocks Pro',
+ licenseType: 'poc',
+ company: '',
+ applicantName: '',
+ salesRepName: '',
+ applicantEmail: '',
+ applicantPhone: '',
+ notes: '',
+};
+
+const UPGRADE_PAGE_MARKER = 'flocks-upgrade-in-progress';
+const DISMISSED_REJECTED_REQUESTS_KEY = 'flockspro-dismissed-rejected-requests';
+const HEALTH_POLL_INTERVAL = 2000;
+const HEALTH_POLL_TIMEOUT = 5 * 60 * 1000;
+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+function isValidInternationalPhone(value: string): boolean {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return false;
+ }
+ if (!/^[+\d\s()-]+$/.test(trimmed)) {
+ return false;
+ }
+ if ((trimmed.match(/\+/g) || []).length > 1 || (trimmed.includes('+') && !trimmed.startsWith('+'))) {
+ return false;
+ }
+ const digits = trimmed.replace(/[^\d]/g, '');
+ return digits.length >= 6 && digits.length <= 15;
+}
+
+function loadDismissedRejectedRequestIds(): Set {
+ if (typeof window === 'undefined') {
+ return new Set();
+ }
+ try {
+ const raw = window.localStorage.getItem(DISMISSED_REJECTED_REQUESTS_KEY);
+ const parsed: unknown = raw ? JSON.parse(raw) : [];
+ if (!Array.isArray(parsed)) {
+ return new Set();
+ }
+ return new Set(parsed.filter((item): item is string => typeof item === 'string' && item.length > 0));
+ } catch {
+ return new Set();
+ }
+}
+
+function saveDismissedRejectedRequestIds(ids: Set): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ try {
+ window.localStorage.setItem(DISMISSED_REJECTED_REQUESTS_KEY, JSON.stringify([...ids].slice(-200)));
+ } catch {
+ // Ignore storage failures; the dismissal still applies for the current page session.
+ }
+}
+
+async function getFlocksproLicenseStatus(): Promise {
+ const response = await client.get('/api/flockspro/license/status');
+ return response.data;
+}
+
+async function refreshFlocksproLicenseStatus(): Promise {
+ await client.post('/api/flockspro/license/refresh').catch(() => undefined);
+ return getFlocksproLicenseStatus();
+}
+
+function proPackageStatusToLicenseStatus(status: ProPackageStatus): FlocksproLicenseStatus {
+ return {
+ activated: false,
+ active: false,
+ license_status: status.license_status || 'uninstalled',
+ inactive_reason: status.inactive_reason || 'flockspro_not_installed',
+ pro_enabled: status.pro_enabled ?? false,
+ };
+}
+
+function formatProVersion(version?: string | null): string {
+ const normalized = (version || '').trim().replace(/^pro-v/i, '').replace(/^v/i, '');
+ return normalized ? `pro-v${normalized}` : 'pro-v...';
+}
+
+function formatDateTimeValue(value?: string | number | null): string {
+ if (value === null || value === undefined || value === '') {
+ return '-';
+ }
+ const d = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
+ if (Number.isNaN(d.getTime())) {
+ return String(value);
+ }
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+}
+
+function daysRemaining(value?: string | number | null): number | null {
+ if (value === null || value === undefined || value === '') {
+ return null;
+ }
+ const d = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
+ if (Number.isNaN(d.getTime())) {
+ return null;
+ }
+ return Math.max(0, Math.ceil((d.getTime() - Date.now()) / 86400000));
+}
+
+function formatLicenseValue(key: string, value: string | number | boolean | null | undefined): string {
+ if (value === null || value === undefined || value === '') {
+ return '-';
+ }
+ if (typeof value === 'boolean') {
+ return value ? 'true' : 'false';
+ }
+ if (key.endsWith('_at') || key.endsWith('At')) {
+ return formatDateTimeValue(value);
+ }
+ return String(value);
+}
+
+function compactIdentifier(value?: string | null, head = 10, tail = 8): string {
+ if (!value) {
+ return '-';
+ }
+ if (value.length <= head + tail + 3) {
+ return value;
+ }
+ return `${value.slice(0, head)}...${value.slice(-tail)}`;
+}
+
+function clampPercent(value?: number | null): number | null {
+ if (typeof value !== 'number' || Number.isNaN(value)) {
+ return null;
+ }
+ return Math.max(0, Math.min(100, Math.round(value)));
+}
+
+function formatBytes(value?: number | null): string {
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
+ return '-';
+ }
+ const units = ['B', 'KB', 'MB', 'GB'];
+ let size = value;
+ let unitIndex = 0;
+ while (size >= 1024 && unitIndex < units.length - 1) {
+ size /= 1024;
+ unitIndex += 1;
+ }
+ const precision = unitIndex === 0 || size >= 10 ? 0 : 1;
+ return `${size.toFixed(precision)} ${units[unitIndex]}`;
+}
+
+function normalizeLicenseType(value?: string | null): 'poc' | 'commercial' | null {
+ const normalized = String(value || '').trim().toLowerCase();
+ if (normalized === 'poc') {
+ return 'poc';
+ }
+ if (normalized === 'commercial') {
+ return 'commercial';
+ }
+ return null;
+}
+
+function requestAccountKey(item: UpgradeRequestStatus): string {
+ return String(
+ item.details?.console_account_name ||
+ item.details?.cloud_account ||
+ item.details?.passport_uid ||
+ item.details?.account ||
+ '',
+ ).trim().toLowerCase();
+}
+
+function isRequestForCurrentAccount(item: UpgradeRequestStatus, currentAccountKey: string): boolean {
+ if (!currentAccountKey) {
+ return true;
+ }
+ const accountKey = requestAccountKey(item);
+ return !accountKey || accountKey === currentAccountKey;
+}
+
+function requestLicenseId(item: UpgradeRequestStatus): string {
+ return String(item.license_id || item.details?.license_id || item.activate_key || item.request_id || '-');
+}
+
+function requestHasIssuedLicense(item: UpgradeRequestStatus): boolean {
+ return Boolean(item.license_id || item.details?.license_id || item.activate_key);
+}
+
+function requestCanInstallProPackage(item: UpgradeRequestStatus): boolean {
+ const status = (item.status || '').toLowerCase();
+ return ['approved', 'activated'].includes(status) && requestHasIssuedLicense(item) && !requestLicenseInactive(item);
+}
+
+function requestExpiresAt(item: UpgradeRequestStatus): string | number | null | undefined {
+ return item.expires_at || item.details?.license_effective_expires_at || item.details?.expires_at;
+}
+
+function requestLicenseStatus(item: UpgradeRequestStatus): string {
+ return (
+ item.license_status ||
+ item.details?.license_status ||
+ normalizeLicenseType(item.details?.license_type)?.toString() ||
+ item.status ||
+ '-'
+ );
+}
+
+function isInactiveLicenseStatus(value?: string | null): boolean {
+ return ['revoked', 'expired', 'superseded'].includes(String(value || '').trim().toLowerCase());
+}
+
+function requestLicenseInactive(item: UpgradeRequestStatus): boolean {
+ return isInactiveLicenseStatus(item.license_status) || isInactiveLicenseStatus(item.details?.license_status);
+}
+
+function requestMaxAdmins(item: UpgradeRequestStatus): number | null | undefined {
+ return item.max_admins ?? (typeof item.details?.max_admins === 'number' ? item.details.max_admins : null);
+}
+
+function requestMaxMembers(item: UpgradeRequestStatus): number | null | undefined {
+ return item.max_members ?? (typeof item.details?.max_members === 'number' ? item.details.max_members : null);
+}
+
+function requestCreatedTime(item: UpgradeRequestStatus): number {
+ const created = new Date(item.created_at || item.updated_at).getTime();
+ return Number.isNaN(created) ? 0 : created;
+}
+
+function requestDurationDays(item: UpgradeRequestStatus): number | null {
+ if (typeof item.details?.license_duration_days === 'number') {
+ return item.details.license_duration_days;
+ }
+ const expiresAt = requestExpiresAt(item);
+ const createdAt = new Date(item.created_at);
+ const expiresDate =
+ typeof expiresAt === 'number' ? new Date(expiresAt * 1000) : expiresAt ? new Date(expiresAt) : null;
+ if (!expiresDate || Number.isNaN(expiresDate.getTime()) || Number.isNaN(createdAt.getTime())) {
+ return null;
+ }
+ return Math.max(1, Math.ceil((expiresDate.getTime() - createdAt.getTime()) / 86400000));
+}
+
+export default function FlocksproUpgradePage() {
+ const { t } = useTranslation('flockspro');
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [consoleLoginStatus, setConsoleLoginStatus] = useState(null);
+ const [consoleLoginLoading, setConsoleLoginLoading] = useState(false);
+ const [consoleLoginError, setConsoleLoginError] = useState(null);
+ const [consoleLoginSuccess, setConsoleLoginSuccess] = useState(null);
+ const [requests, setRequests] = useState([]);
+ const [requestError, setRequestError] = useState(null);
+ const [activeRequestId, setActiveRequestId] = useState(null);
+ const [showApplyDialog, setShowApplyDialog] = useState(false);
+ const [submittingApply, setSubmittingApply] = useState(false);
+ const [applyForm, setApplyForm] = useState(DEFAULT_FORM);
+ const [applyFormError, setApplyFormError] = useState(null);
+ const [showUpdateModal, setShowUpdateModal] = useState(false);
+ const [upgradeSteps, setUpgradeSteps] = useState([]);
+ const [upgradeError, setUpgradeError] = useState(null);
+ const [proUpgrading, setProUpgrading] = useState(false);
+ const [proRestarting, setProRestarting] = useState(false);
+ const [refreshingInstalled, setRefreshingInstalled] = useState(false);
+ const [showLicenseDetails, setShowLicenseDetails] = useState(false);
+ const [licenseStatus, setLicenseStatus] = useState(null);
+ const [proPackageStatus, setProPackageStatus] = useState(null);
+ const [dismissedRejectedRequestIds, setDismissedRejectedRequestIds] = useState>(
+ loadDismissedRejectedRequestIds,
+ );
+ const autoSyncTriggeredRef = useRef(false);
+ const consoleAccountName = consoleLoginStatus?.account_name?.trim() ?? '';
+ const currentConsoleAccountKey = consoleLoginStatus?.logged_in ? consoleAccountName.toLowerCase() : '';
+ const isProPackageInstalled = proPackageStatus?.installed === true;
+ const licenseReapplyAllowed =
+ licenseStatus?.reapply_allowed === true ||
+ ['revoked', 'expired'].includes(String(licenseStatus?.license_status || '').toLowerCase());
+ const runtimeLicenseInvalid =
+ licenseReapplyAllowed ||
+ licenseStatus?.active === false ||
+ isInactiveLicenseStatus(licenseStatus?.license_status);
+ const invalidRuntimeLicenseId =
+ runtimeLicenseInvalid && licenseStatus?.license_id ? String(licenseStatus.license_id) : '';
+ const runtimeLicenseId = licenseStatus?.license_id ? String(licenseStatus.license_id) : '';
+ const accountScopedRequests = useMemo(
+ () => requests.filter((item) => isRequestForCurrentAccount(item, currentConsoleAccountKey)),
+ [currentConsoleAccountKey, requests],
+ );
+
+ const currentIssuedRequest = useMemo(
+ () => {
+ const issued = accountScopedRequests.filter((item) => {
+ const status = (item.status || '').toLowerCase();
+ return ['approved', 'activated'].includes(status) && requestHasIssuedLicense(item);
+ });
+ issued.sort((a, b) => requestCreatedTime(b) - requestCreatedTime(a));
+ return issued[0] ?? null;
+ },
+ [accountScopedRequests],
+ );
+
+ const currentIssuedRequestLicenseId = currentIssuedRequest ? requestLicenseId(currentIssuedRequest) : '';
+ const canInstallProPackageFromRequest = useCallback(
+ (item: UpgradeRequestStatus) => {
+ if (!isProPackageInstalled && requestCanInstallProPackage(item) && item.request_id === currentIssuedRequest?.request_id) {
+ if (!runtimeLicenseId) {
+ return true;
+ }
+ return currentIssuedRequestLicenseId === runtimeLicenseId && !runtimeLicenseInvalid;
+ }
+ return false;
+ },
+ [
+ currentIssuedRequest?.request_id,
+ currentIssuedRequestLicenseId,
+ isProPackageInstalled,
+ runtimeLicenseId,
+ runtimeLicenseInvalid,
+ ],
+ );
+
+ const visibleRequests = useMemo(
+ () => {
+ const currentStatuses = ['pending', 'reviewing', 'approved'];
+ return accountScopedRequests.filter((item) => {
+ const status = (item.status || '').toLowerCase();
+ if (status === 'rejected') {
+ return !dismissedRejectedRequestIds.has(item.request_id);
+ }
+ if (status === 'approved') {
+ return !requestHasIssuedLicense(item) || canInstallProPackageFromRequest(item);
+ }
+ if (status === 'activated') {
+ return canInstallProPackageFromRequest(item);
+ }
+ return currentStatuses.includes(status);
+ });
+ },
+ [accountScopedRequests, canInstallProPackageFromRequest, dismissedRejectedRequestIds],
+ );
+
+ const activeRequest = useMemo(
+ () =>
+ visibleRequests.find((item) => item.request_id === activeRequestId) ?? visibleRequests[0] ?? null,
+ [activeRequestId, visibleRequests],
+ );
+
+ const latestActivatedRequest = useMemo(
+ () =>
+ requests.find((item) => {
+ const status = (item.status || '').toLowerCase();
+ const installResult = (item.details?.auto_install_result || '').toLowerCase();
+ return status === 'activated' || ['done', 'already_latest', 'restarting'].includes(installResult);
+ }) ?? null,
+ [requests],
+ );
+
+ const proComponentVersion =
+ latestActivatedRequest?.details?.auto_install_pro_version ||
+ latestActivatedRequest?.details?.flockspro_component_version;
+ const proVersion = formatProVersion(
+ proComponentVersion ||
+ proPackageStatus?.flockspro_component_version ||
+ proPackageStatus?.installed_version ||
+ latestActivatedRequest?.details?.auto_install_version ||
+ latestActivatedRequest?.details?.auto_install_target,
+ );
+ const isProRuntimeActive = licenseStatus?.pro_enabled === true || proPackageStatus?.pro_enabled === true;
+ const canUseProFeatures = isProPackageInstalled && isProRuntimeActive;
+ const isProLoaded = canUseProFeatures;
+ const hasRuntimeLicense = Boolean(licenseStatus?.license_id);
+ const runtimeLicenseUsable = hasRuntimeLicense && !runtimeLicenseInvalid;
+ const preferRequestLicense = Boolean(currentIssuedRequest) && !runtimeLicenseUsable;
+ const currentDisplayLicenseId = preferRequestLicense
+ ? currentIssuedRequestLicenseId
+ : runtimeLicenseUsable
+ ? licenseStatus?.license_id
+ : runtimeLicenseId || undefined;
+ const currentDisplayLicenseRequest = currentDisplayLicenseId
+ ? accountScopedRequests.find((item) => requestLicenseId(item) === currentDisplayLicenseId)
+ : null;
+ const showCurrentLicenseCard = Boolean(currentDisplayLicenseId);
+ const displayedLicenseStatus = (preferRequestLicense
+ ? requestLicenseStatus(currentIssuedRequest as UpgradeRequestStatus)
+ : licenseStatus?.license_status) ||
+ licenseStatus?.status ||
+ '-';
+ const displayedLicenseInactive = isInactiveLicenseStatus(String(displayedLicenseStatus));
+ const currentLicenseInvalid =
+ Boolean(currentDisplayLicenseId) && (displayedLicenseInactive || (!preferRequestLicense && runtimeLicenseInvalid));
+ const displayedExpiresAt =
+ (preferRequestLicense && currentIssuedRequest ? requestExpiresAt(currentIssuedRequest) : licenseStatus?.expires_at) ||
+ (!preferRequestLicense
+ ? latestActivatedRequest?.details?.license_effective_expires_at || latestActivatedRequest?.details?.expires_at
+ : undefined);
+ const remainingDays = daysRemaining(displayedExpiresAt);
+ const displayedMaxAdmins = preferRequestLicense && currentIssuedRequest
+ ? requestMaxAdmins(currentIssuedRequest)
+ : licenseStatus?.max_admins;
+ const displayedMaxMembers = preferRequestLicense && currentIssuedRequest
+ ? requestMaxMembers(currentIssuedRequest)
+ : licenseStatus?.max_members;
+ const displayedLastSyncedAt =
+ preferRequestLicense && currentIssuedRequest
+ ? currentIssuedRequest.details?.license_refreshed_at || currentIssuedRequest.updated_at
+ : licenseStatus?.last_sync_at ||
+ currentDisplayLicenseRequest?.details?.license_refreshed_at ||
+ currentDisplayLicenseRequest?.updated_at ||
+ licenseStatus?.last_heartbeat_ok_at ||
+ latestActivatedRequest?.details?.license_refreshed_at ||
+ latestActivatedRequest?.updated_at;
+ const licenseQuotaText = [
+ displayedMaxAdmins
+ ? t('upgrade.adminQuotaValue', { count: displayedMaxAdmins })
+ : null,
+ displayedMaxMembers
+ ? t('upgrade.memberQuotaValue', { count: displayedMaxMembers })
+ : null,
+ ].filter(Boolean).join(' / ') || '-';
+ const licenseDetailRows = useMemo(() => {
+ if (!licenseStatus && !currentDisplayLicenseId) {
+ return [];
+ }
+ return [
+ ['license_id', currentDisplayLicenseId],
+ ['install_id', preferRequestLicense ? undefined : licenseStatus?.install_id],
+ ['fingerprint', preferRequestLicense ? undefined : licenseStatus?.fingerprint],
+ ]
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
+ .map(([key, value]) => ({
+ key: String(key),
+ label: t(`upgrade.licenseFieldLabels.${key}`),
+ value: formatLicenseValue(String(key), value),
+ }));
+ }, [currentDisplayLicenseId, licenseStatus, preferRequestLicense, t]);
+
+ const refreshConsoleLoginStatus = useCallback(async () => {
+ setConsoleLoginLoading(true);
+ setConsoleLoginError(null);
+ try {
+ const data = await authApi.consoleLoginSession();
+ setConsoleLoginStatus(data);
+ } catch (err) {
+ setConsoleLoginError(extractErrorMessage(err, t('errors.fetchConsoleLoginStatus')));
+ } finally {
+ setConsoleLoginLoading(false);
+ }
+ }, [t]);
+
+ const refreshRequests = useCallback(async () => {
+ setRequestError(null);
+ try {
+ const data = await consoleUpgradeApi.listRequests();
+ setRequests(data);
+ const currentStatuses = ['pending', 'reviewing', 'approved'];
+ const nextVisible = data.filter((item) => {
+ if (!isRequestForCurrentAccount(item, currentConsoleAccountKey)) {
+ return false;
+ }
+ const status = (item.status || '').toLowerCase();
+ if (status === 'rejected') {
+ return !dismissedRejectedRequestIds.has(item.request_id);
+ }
+ if (status === 'activated') {
+ return canInstallProPackageFromRequest(item);
+ }
+ if (status === 'approved') {
+ return !requestHasIssuedLicense(item) || canInstallProPackageFromRequest(item);
+ }
+ return currentStatuses.includes(status);
+ });
+ setActiveRequestId((prev) => {
+ if (prev && nextVisible.some((item) => item.request_id === prev)) {
+ return prev;
+ }
+ return nextVisible[0]?.request_id ?? null;
+ });
+ } catch (err) {
+ setRequestError(extractErrorMessage(err, t('errors.fetchRequests')));
+ }
+ }, [canInstallProPackageFromRequest, currentConsoleAccountKey, dismissedRejectedRequestIds, t]);
+
+ useEffect(() => {
+ if (!activeRequestId) {
+ return;
+ }
+ if (!visibleRequests.some((item) => item.request_id === activeRequestId)) {
+ setActiveRequestId(visibleRequests[0]?.request_id ?? null);
+ }
+ }, [activeRequestId, visibleRequests]);
+
+ useEffect(() => {
+ void refreshConsoleLoginStatus();
+ void refreshRequests();
+ }, [refreshConsoleLoginStatus, refreshRequests]);
+
+ useEffect(() => {
+ let cancelled = false;
+ void refreshFlocksproLicenseStatus()
+ .then((status) => {
+ if (!cancelled) {
+ setLicenseStatus(status);
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setLicenseStatus(null);
+ }
+ });
+ void consoleUpgradeApi.getProPackageStatus()
+ .then((status) => {
+ if (!cancelled) {
+ setProPackageStatus(status);
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setProPackageStatus(null);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ const loginResult = searchParams.get('login');
+ const consoleLoginStatusParam = searchParams.get('console_login_status');
+ const consoleLoginId = searchParams.get('console_login_id');
+ const state = searchParams.get('state') ?? undefined;
+ const passportUid = searchParams.get('passport_uid') ?? undefined;
+ if (!loginResult && !consoleLoginStatusParam) {
+ return;
+ }
+ let cancelled = false;
+ const finalize = async () => {
+ try {
+ if (loginResult === 'success') {
+ await refreshConsoleLoginStatus();
+ } else if (consoleLoginStatusParam === 'success' && consoleLoginId) {
+ await authApi.finishConsoleLogin(consoleLoginId, state, passportUid);
+ await refreshConsoleLoginStatus();
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setConsoleLoginError(extractErrorMessage(err, t('errors.finishConsoleLogin')));
+ }
+ } finally {
+ if (!cancelled) {
+ const nextParams = new URLSearchParams(searchParams);
+ nextParams.delete('login');
+ nextParams.delete('message');
+ nextParams.delete('console_login_status');
+ nextParams.delete('console_login_id');
+ nextParams.delete('state');
+ nextParams.delete('passport_uid');
+ setSearchParams(nextParams, { replace: true });
+ }
+ }
+ };
+ void finalize();
+ return () => {
+ cancelled = true;
+ };
+ }, [refreshConsoleLoginStatus, searchParams, setSearchParams, t]);
+
+ const startConsoleLogin = async () => {
+ setConsoleLoginError(null);
+ setConsoleLoginSuccess(null);
+ try {
+ const returnTo = `${window.location.origin}/flockspro-upgrade/callback`;
+ const result = await authApi.startConsoleLogin(returnTo);
+ window.location.href = result.passport_login_url;
+ } catch (err) {
+ setConsoleLoginError(extractErrorMessage(err, t('errors.startConsoleLogin')));
+ }
+ };
+
+ const logoutConsoleLogin = async () => {
+ setConsoleLoginError(null);
+ setConsoleLoginSuccess(null);
+ try {
+ await authApi.logoutConsoleLogin();
+ await refreshConsoleLoginStatus();
+ } catch (err) {
+ setConsoleLoginError(extractErrorMessage(err, t('errors.logoutConsoleLogin')));
+ }
+ };
+
+ const createUpgradeRequest = async () => {
+ const company = applyForm.company.trim();
+ const applicantName = applyForm.applicantName.trim();
+ const applicantEmail = applyForm.applicantEmail.trim();
+ const applicantPhone = applyForm.applicantPhone.trim();
+ if (!company || !applicantName || !applicantEmail || !applicantPhone) {
+ setApplyFormError(t('upgrade.formRequiredError'));
+ return;
+ }
+ if (!EMAIL_PATTERN.test(applicantEmail)) {
+ setApplyFormError(t('upgrade.invalidEmailError'));
+ return;
+ }
+ if (!isValidInternationalPhone(applicantPhone)) {
+ setApplyFormError(t('upgrade.invalidPhoneError'));
+ return;
+ }
+
+ setSubmittingApply(true);
+ setRequestError(null);
+ setApplyFormError(null);
+ try {
+ const payload: UpgradeRequestCreatePayload = {
+ product: applyForm.product,
+ license_type: applyForm.licenseType,
+ request_kind: 'new',
+ company,
+ applicant_name: applicantName,
+ applicant_email: applicantEmail,
+ applicant_phone: applicantPhone,
+ notes: applyForm.notes.trim() || undefined,
+ };
+ const created = await consoleUpgradeApi.createRequest(payload);
+ setDismissedRejectedRequestIds((prev) => {
+ const next = new Set(prev);
+ requests
+ .filter((item) => (item.status || '').toLowerCase() === 'rejected')
+ .forEach((item) => next.add(item.request_id));
+ saveDismissedRejectedRequestIds(next);
+ return next;
+ });
+ setRequests((prev) => [created, ...prev]);
+ setActiveRequestId(created.request_id);
+ setShowApplyDialog(false);
+ setApplyForm(DEFAULT_FORM);
+ } catch (err) {
+ setApplyFormError(extractErrorMessage(err, t('errors.createRequest')));
+ } finally {
+ setSubmittingApply(false);
+ }
+ };
+
+ const refreshActiveRequest = async () => {
+ if (!activeRequest) {
+ return;
+ }
+ try {
+ const latest = await consoleUpgradeApi.refreshRequest(activeRequest.request_id);
+ setRequests((prev) =>
+ prev.map((item) => (item.request_id === latest.request_id ? latest : item)),
+ );
+ } catch (err) {
+ setRequestError(extractErrorMessage(err, t('errors.refreshRequest')));
+ }
+ };
+
+ const refreshInstalledStatus = useCallback(async () => {
+ setRefreshingInstalled(true);
+ setRequestError(null);
+ try {
+ await consoleUpgradeApi.syncRevocations().catch(() => undefined);
+ await refreshRequests();
+ const packageStatus = await consoleUpgradeApi.getProPackageStatus();
+ setProPackageStatus(packageStatus);
+ if (!packageStatus.installed) {
+ setLicenseStatus(proPackageStatusToLicenseStatus(packageStatus));
+ window.dispatchEvent(new Event('flockspro-license-status-changed'));
+ return;
+ }
+ const status = await refreshFlocksproLicenseStatus();
+ setLicenseStatus(status);
+ window.dispatchEvent(new Event('flockspro-license-status-changed'));
+ } catch (err) {
+ setRequestError(extractErrorMessage(err, t('errors.refreshRequest')));
+ } finally {
+ setRefreshingInstalled(false);
+ }
+ }, [refreshRequests, t]);
+
+ useEffect(() => {
+ if (autoSyncTriggeredRef.current) {
+ return;
+ }
+ if (!isProPackageInstalled || isProRuntimeActive || !currentIssuedRequest || refreshingInstalled) {
+ return;
+ }
+ autoSyncTriggeredRef.current = true;
+ void refreshInstalledStatus();
+ }, [
+ currentIssuedRequest,
+ isProPackageInstalled,
+ isProRuntimeActive,
+ refreshInstalledStatus,
+ refreshingInstalled,
+ ]);
+
+ const cancelActiveRequest = async () => {
+ if (!activeRequest) {
+ return;
+ }
+ try {
+ const latest = await consoleUpgradeApi.cancelRequest(activeRequest.request_id);
+ setRequests((prev) =>
+ prev.map((item) => (item.request_id === latest.request_id ? latest : item)),
+ );
+ } catch (err) {
+ setRequestError(extractErrorMessage(err, t('errors.cancelRequest')));
+ }
+ };
+
+ const upsertUpgradeStep = (progress: UpdateProgress) => {
+ setUpgradeSteps((prev) => {
+ const existingIndex = prev.findIndex((item) => item.stage === progress.stage);
+ if (existingIndex === -1) {
+ return [...prev, progress];
+ }
+ const next = [...prev];
+ next[existingIndex] = progress;
+ return next;
+ });
+ };
+
+ const pollUntilReady = () => {
+ const startedAt = Date.now();
+ const poll = async () => {
+ if (Date.now() - startedAt > HEALTH_POLL_TIMEOUT) {
+ setUpgradeError(t('upgrade.restartTimeout'));
+ setProRestarting(false);
+ setProUpgrading(false);
+ return;
+ }
+ try {
+ const healthResponse = await fetch('/api/health', { cache: 'no-store' });
+ if (healthResponse.ok) {
+ const rootResponse = await fetch('/', { cache: 'no-store' });
+ const rootHtml = await rootResponse.text();
+ const stillShowingUpgradePage = rootHtml.includes(UPGRADE_PAGE_MARKER);
+ if (rootResponse.ok && !stillShowingUpgradePage) {
+ window.location.assign(`${window.location.pathname}${window.location.search}`);
+ return;
+ }
+ }
+ } catch {
+ // Backend may be restarting.
+ }
+ setTimeout(() => {
+ void poll();
+ }, HEALTH_POLL_INTERVAL);
+ };
+ setTimeout(() => {
+ void poll();
+ }, 1500);
+ };
+
+ const startProUpgrade = async () => {
+ if (!activeRequest) {
+ return;
+ }
+ setShowUpdateModal(true);
+ setProUpgrading(true);
+ setProRestarting(false);
+ setUpgradeError(null);
+ setUpgradeSteps([]);
+ let sawRestarting = false;
+ try {
+ await consoleUpgradeApi.startRequest(activeRequest.request_id, (progress) => {
+ upsertUpgradeStep(progress);
+ if (progress.stage === 'restarting') {
+ sawRestarting = true;
+ setProUpgrading(false);
+ setProRestarting(true);
+ pollUntilReady();
+ }
+ });
+ if (!sawRestarting) {
+ setProUpgrading(false);
+ await refreshRequests();
+ const packageStatus = await consoleUpgradeApi.getProPackageStatus();
+ setProPackageStatus(packageStatus);
+ const status = await refreshFlocksproLicenseStatus();
+ setLicenseStatus(status);
+ window.dispatchEvent(new Event('flockspro-license-status-changed'));
+ }
+ } catch (err) {
+ if (!sawRestarting) {
+ setUpgradeError(extractErrorMessage(err, t('errors.startUpgrade')));
+ setProUpgrading(false);
+ }
+ }
+ };
+
+ const canApplyUpgrade = consoleLoginStatus?.logged_in === true;
+ const hasOpenRequest = accountScopedRequests.some((item) => {
+ const status = (item.status || '').toLowerCase();
+ if (['pending', 'reviewing'].includes(status)) {
+ return true;
+ }
+ return (
+ (status === 'approved' && !requestHasIssuedLicense(item)) ||
+ canInstallProPackageFromRequest(item)
+ );
+ });
+ const canOpenApplyDialog = canApplyUpgrade && !hasOpenRequest;
+ const showApprovedActions = Boolean(activeRequest && canInstallProPackageFromRequest(activeRequest));
+ const showRejectedFeedback = activeRequest?.status === 'rejected';
+ const canCancel =
+ activeRequest?.status === 'pending' ||
+ activeRequest?.status === 'reviewing' ||
+ activeRequest?.status === 'approved';
+ const primaryActionLabel = t('upgrade.applyNewLicenseAction');
+ const activeRequestIsCurrentLicense =
+ Boolean(activeRequest && currentIssuedRequest) &&
+ activeRequest?.request_id === currentIssuedRequest?.request_id &&
+ showCurrentLicenseCard;
+ const showActiveRequestCard = Boolean(activeRequest) && !(activeRequestIsCurrentLicense && isProPackageInstalled);
+ const historyRequests = accountScopedRequests.filter((item) => {
+ if (item.request_id === currentIssuedRequest?.request_id) {
+ return false;
+ }
+ if (showActiveRequestCard && item.request_id === activeRequest?.request_id) {
+ return false;
+ }
+ return true;
+ });
+
+ const dismissRejectedRequest = (requestId: string) => {
+ setDismissedRejectedRequestIds((prev) => {
+ const next = new Set(prev);
+ next.add(requestId);
+ saveDismissedRejectedRequestIds(next);
+ return next;
+ });
+ setActiveRequestId((prev) => (prev === requestId ? null : prev));
+ };
+
+ const formatDateTime = (value?: string | null): string => {
+ return formatDateTimeValue(value);
+ };
+
+ return (
+
+
}
+ />
+
+
+
{t('consoleLogin.title')}
+
+
+
+ {t('consoleLogin.accountLabel')}
+
+ {consoleLoginLoading
+ ? t('consoleLogin.loading')
+ : consoleLoginStatus?.logged_in
+ ? consoleAccountName
+ : t('consoleLogin.unbound')}
+
+
+ {consoleLoginStatus?.logged_in ? (
+
+ void logoutConsoleLogin()}
+ className="inline-flex items-center gap-2 rounded-lg border border-red-300 px-3 py-2 text-sm text-red-700 hover:bg-red-50"
+ >
+ {t('consoleLogin.logoutAction')}
+
+
+ ) : (
+
void startConsoleLogin()}
+ className="inline-flex items-center gap-2 rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800"
+ >
+
+ {t('consoleLogin.loginAction')}
+
+ )}
+
+
+
+ {consoleLoginError && (
+
+ {consoleLoginError}
+
+ )}
+ {consoleLoginSuccess && (
+
+ {consoleLoginSuccess}
+
+ )}
+
+
+
+
+
+
+ {isProLoaded ? t('upgrade.installedTitle', { version: proVersion }) : t('upgrade.title')}
+
+
+ {isProLoaded ? t('upgrade.installedDescription') : t('upgrade.description')}
+
+
+ {isProLoaded ? (
+
+ {
+ if (!canOpenApplyDialog) {
+ return;
+ }
+ setApplyFormError(null);
+ setShowApplyDialog(true);
+ }}
+ disabled={!canOpenApplyDialog}
+ className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
+ >
+ {primaryActionLabel}
+
+ {!showCurrentLicenseCard && (
+ void refreshInstalledStatus()}
+ disabled={refreshingInstalled}
+ className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:bg-gray-300 disabled:cursor-not-allowed"
+ >
+ {refreshingInstalled ? t('upgrade.syncingLicense') : t('upgrade.syncLicenseAction')}
+
+ )}
+
+ ) : (
+
{
+ if (!canOpenApplyDialog) {
+ return;
+ }
+ if (showRejectedFeedback && activeRequest) {
+ dismissRejectedRequest(activeRequest.request_id);
+ }
+ setApplyFormError(null);
+ setShowApplyDialog(true);
+ }}
+ disabled={!canOpenApplyDialog}
+ className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:bg-gray-300 disabled:cursor-not-allowed"
+ >
+ {primaryActionLabel}
+
+ )}
+
+
+ {!canApplyUpgrade && (
+
+ {t('upgrade.loginFirst')}
+
+ )}
+ {requestError && (
+
+ {requestError}
+
+ )}
+
+ {showActiveRequestCard && activeRequest ? (
+
+
+
{t('upgrade.currentRequest')}
+
+
{activeRequest.request_id}
+ {showRejectedFeedback && (
+
dismissRejectedRequest(activeRequest.request_id)}
+ className="rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-700"
+ aria-label={t('upgrade.dismissRejected')}
+ title={t('upgrade.dismissRejected')}
+ >
+
+
+ )}
+
+
+
+
{t('upgrade.status')}
+
+ {t(`upgrade.statusLabels.${activeRequest.status}`, { defaultValue: activeRequest.status })}
+
+
+
+
{t('upgrade.updatedAt')}
+
{formatDateTime(activeRequest.updated_at)}
+
+ {showRejectedFeedback && (
+
+
{t('upgrade.rejectedTitle')}
+ {activeRequest.reason &&
{activeRequest.reason}
}
+ {activeRequest.suggestion &&
{activeRequest.suggestion}
}
+
+ )}
+ {!showRejectedFeedback && activeRequest.suggestion && (
+
+ {activeRequest.suggestion}
+
+ )}
+
+ void refreshActiveRequest()}
+ className="rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
+ >
+ {t('upgrade.manualRefresh')}
+
+ {canCancel && (
+ void cancelActiveRequest()}
+ className="rounded-lg border border-red-300 px-3 py-2 text-sm text-red-700 hover:bg-red-50"
+ >
+ {t('upgrade.cancel')}
+
+ )}
+ {showApprovedActions && (
+ void startProUpgrade()}
+ disabled={proUpgrading || proRestarting}
+ className="ml-auto rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700"
+ >
+ {t('upgrade.startUpgrade')}
+
+ )}
+
+ {showApprovedActions && (
+
+ {t('upgrade.afterUpgradeHint')}
+
+ )}
+
+ ) : !isProLoaded && !showCurrentLicenseCard ? (
+
+ {t('upgrade.noRequest')}
+
+ ) : (
+ null
+ )}
+
+ {showCurrentLicenseCard && (
+
+ {currentLicenseInvalid && (
+
+ {t('upgrade.revokedOrExpiredHint')}
+
+ )}
+
+
+
+ {proVersion}
+
+ {t(`upgrade.licenseStatusLabels.${displayedLicenseStatus}`, { defaultValue: displayedLicenseStatus })}
+
+
+
+ {t('upgrade.licenseId')}: {compactIdentifier(currentDisplayLicenseId)}
+
+
+
void refreshInstalledStatus()}
+ disabled={refreshingInstalled}
+ className={`rounded-lg border bg-white/70 px-3 py-2 text-xs font-medium disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-white/50 disabled:text-gray-400 ${
+ currentLicenseInvalid
+ ? 'border-red-300 text-red-700 hover:bg-red-50'
+ : 'border-emerald-300 text-emerald-700 hover:bg-emerald-50'
+ }`}
+ >
+ {refreshingInstalled ? t('upgrade.syncingLicense') : t('upgrade.syncLicenseAction')}
+
+
+
+
+
{t('upgrade.remainingDays')}
+
+ {remainingDays === null ? '-' : t('upgrade.remainingDaysValue', { count: remainingDays })}
+
+
+
+
{t('upgrade.quota')}
+
{licenseQuotaText}
+
+
+
{t('upgrade.expiresAt')}
+
{formatDateTimeValue(displayedExpiresAt)}
+
+
+
{t('upgrade.lastSyncedAt')}
+
+ {formatDateTimeValue(displayedLastSyncedAt)}
+
+
+
+ {licenseDetailRows.length > 0 && (
+
+
setShowLicenseDetails((prev) => !prev)}
+ className={`inline-flex items-center gap-1 text-xs font-medium ${
+ currentLicenseInvalid ? 'text-red-700 hover:text-red-900' : 'text-emerald-700 hover:text-emerald-900'
+ }`}
+ >
+
+ {showLicenseDetails ? t('upgrade.hideLicenseDetails') : t('upgrade.showLicenseDetails')}
+
+ {showLicenseDetails && (
+
+ {licenseDetailRows.map((item) => (
+
+
{item.label}
+
{item.value}
+
+ ))}
+
+ )}
+
+ )}
+
+ )}
+
+ {historyRequests.length > 0 && (
+
+
+ {t('upgrade.licenseHistory')}
+
+
+
+
+
+ {[
+ t('upgrade.historyColumns.licenseId'),
+ t('upgrade.historyColumns.licenseType'),
+ t('upgrade.historyColumns.status'),
+ t('upgrade.historyColumns.appliedAt'),
+ t('upgrade.historyColumns.expiresAt'),
+ t('upgrade.historyColumns.durationDays'),
+ t('upgrade.historyColumns.account'),
+ ].map((header) => (
+ |
+ {header}
+ |
+ ))}
+
+
+
+ {historyRequests.map((item) => {
+ const expiresAt = requestExpiresAt(item);
+ const duration = requestDurationDays(item);
+ const account = requestAccountKey(item) || '-';
+ const itemLicenseId = requestLicenseId(item);
+ const historyLicenseType =
+ normalizeLicenseType(item.details?.license_type) ||
+ normalizeLicenseType(item.license_status) ||
+ normalizeLicenseType(item.details?.license_status);
+ const rawLicenseStatus = item.license_status || item.details?.license_status || '';
+ const licenseStatusIsType = Boolean(normalizeLicenseType(rawLicenseStatus));
+ const historyStatus = itemLicenseId === invalidRuntimeLicenseId
+ ? 'revoked'
+ : licenseStatusIsType
+ ? item.status
+ : rawLicenseStatus || item.status;
+ const historyStatusLabelGroup =
+ isInactiveLicenseStatus(historyStatus) || historyStatus === 'active'
+ ? 'licenseStatusLabels'
+ : 'statusLabels';
+ return (
+
+ | {compactIdentifier(itemLicenseId)} |
+
+ {historyLicenseType
+ ? t(`upgrade.licenseTypeLabels.${historyLicenseType}`, { defaultValue: historyLicenseType })
+ : '-'}
+ |
+
+ {t(`upgrade.${historyStatusLabelGroup}.${historyStatus}`, { defaultValue: historyStatus })}
+ |
+ {formatDateTime(item.created_at)} |
+ {formatDateTimeValue(expiresAt)} |
+ {duration ?? '-'} |
+ {account} |
+
+ );
+ })}
+
+
+
+
+ )}
+
+
+
+ {showApplyDialog && (
+
+
+
{t('upgrade.applyDialogTitle')}
+
+
+
{t('upgrade.productLabel')}
+
+
+
+
{t('upgrade.licenseTypeLabel')}
+
+
+
setApplyForm((prev) => ({ ...prev, company: event.target.value }))}
+ placeholder={t('upgrade.companyPlaceholderRequired')}
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-200"
+ />
+
setApplyForm((prev) => ({ ...prev, applicantName: event.target.value }))}
+ placeholder={t('upgrade.applicantNamePlaceholderRequired')}
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-200"
+ />
+
setApplyForm((prev) => ({ ...prev, applicantEmail: event.target.value }))}
+ placeholder={t('upgrade.applicantEmailPlaceholder')}
+ required
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-200"
+ />
+
setApplyForm((prev) => ({ ...prev, applicantPhone: event.target.value }))}
+ placeholder={t('upgrade.applicantPhonePlaceholder')}
+ required
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-200"
+ />
+
+ {applyFormError && (
+
+ {applyFormError}
+
+ )}
+
+ {
+ setApplyFormError(null);
+ setShowApplyDialog(false);
+ }}
+ className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
+ >
+ {t('actions.cancel')}
+
+ void createUpgradeRequest()}
+ disabled={submittingApply}
+ className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:bg-gray-300"
+ >
+ {submittingApply ? t('actions.submitting') : t('actions.submit')}
+
+
+
+
+ )}
+
+ {showUpdateModal && (
+
+
+
+
{t('upgrade.startUpgrade')}
+ setShowUpdateModal(false)}
+ disabled={proUpgrading || proRestarting}
+ className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
+ aria-label={t('actions.cancel')}
+ >
+
+
+
+
+ {proRestarting ? t('upgrade.waitingRestart') : t('upgrade.installingHint')}
+
+ {upgradeSteps.length > 0 && (
+
+ {upgradeSteps.map((step) => {
+ const isError = step.stage === 'error';
+ const isRunning = step.stage === 'restarting' && proRestarting;
+ const downloadPercent = clampPercent(step.percent);
+ const hasDownloadProgress = step.stage === 'fetching' && typeof step.downloaded_bytes === 'number';
+ return (
+
+ {isError ? (
+
+ ) : isRunning ? (
+
+ ) : (
+
+ )}
+
+
+ {t(`upgrade.stageLabels.${step.stage}`, { defaultValue: step.stage })}
+
+
+ {step.message}
+
+ {step.bundle_filename && (
+
+
+ {t('upgrade.bundleFilename')}:{' '}
+ {step.bundle_filename}
+
+
+ )}
+ {hasDownloadProgress && (
+
+
+
+ {downloadPercent === null
+ ? t('upgrade.downloadProgressUnknown', {
+ downloaded: formatBytes(step.downloaded_bytes),
+ })
+ : t('upgrade.downloadProgressLabel', {
+ percent: downloadPercent,
+ downloaded: formatBytes(step.downloaded_bytes),
+ total: formatBytes(step.total_bytes),
+ })}
+
+
+
+
+ )}
+
+
+ );
+ })}
+
+ )}
+ {upgradeError && (
+
+ {upgradeError}
+
+ )}
+
+ {!proUpgrading && !proRestarting && (
+ setShowUpdateModal(false)}
+ className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800"
+ >
+ {t('actions.confirm')}
+
+ )}
+
+
+
+ )}
+
+ );
+}
+
diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx
index 9390aa677..f4dc9f406 100644
--- a/webui/src/pages/Session/index.tsx
+++ b/webui/src/pages/Session/index.tsx
@@ -4,7 +4,7 @@ import {
ChevronDown, Sparkles, Shield, Search, AlertTriangle,
PanelLeftClose, PanelLeft, Bot, Loader2,
Workflow as WorkflowIcon, Settings2, CheckSquare,
- MoreHorizontal, PencilLine, Download,
+ MoreHorizontal, PencilLine, Download, Share2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
@@ -366,6 +366,21 @@ export default function SessionPage() {
}
}, [t, toast]);
+ const handleShareSession = useCallback(async (sessionId: string, nextShared: boolean) => {
+ try {
+ if (nextShared) {
+ await sessionApi.shareLocal(sessionId);
+ toast.success(t('shareEnabled'));
+ } else {
+ await sessionApi.unshareLocal(sessionId);
+ toast.success(t('shareDisabled'));
+ }
+ await refetchSessions();
+ } catch (err: any) {
+ toast.error(t('shareUpdateFailed'), err.message);
+ }
+ }, [refetchSessions, t, toast]);
+
const handleEnterSelectMode = useCallback(() => {
setSelectMode(true);
setCheckedIds(new Set());
@@ -542,7 +557,14 @@ export default function SessionPage() {
data-session-rename-input
/>
) : (
- {session.title}
+
+ {session.title}
+ {session.isShared && (
+
+ {t('sharedTag')}
+
+ )}
+
)}
{/* Timestamp row */}
@@ -671,6 +693,7 @@ export default function SessionPage() {
key={selectedSessionId ?? 'empty-session'}
sessionId={selectedSessionId}
live={Boolean(selectedSessionId)}
+ hideInput={selectedSession?.canWrite === false}
display={{ compact: false, showActions: true, showTimestamp: true }}
agentName={selectedAgent}
className="flex-1 min-h-0"
@@ -765,6 +788,14 @@ export default function SessionPage() {