feat(manager): license banners for offline licensing (KI-006)

Expiry reminders from 14 days (was 5) with a 'connect the server to the
internet to renew' hint while offline; banners for a clock set back and for
a not-yet-verified license; a quiet note after 7+ days without cloud contact
stating the system keeps working until the license date. Grace period
banner respects grace_over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:16:32 +03:00
co-authored by Claude Opus 5.5
parent 13a451a756
commit 0ec3d4735b
2 changed files with 59 additions and 8 deletions
@@ -15,11 +15,18 @@ import client from '../api/client'
* grace_expires_at string | null (ISO) * grace_expires_at string | null (ISO)
* grace_days_remaining number | null * grace_days_remaining number | null
* sync_failed bool * sync_failed bool
* offline_days number | null days since the last successful cloud contact
* license_problem "clock" | "unverified" | "expired" | "inactive" | null
* grace_over bool
*
* The site enforces its signed license offline (KI-006): it keeps running
* until expiry however long it has no internet — offline_days is informational.
* *
* Derived helpers: * Derived helpers:
* showExpiryWarning bool — true when 0 < days_until_expiry <= 5 * showExpiryWarning bool — true when 0 <= days_until_expiry <= 14
* inGracePeriod bool — true when expired but grace not yet over * inGracePeriod bool — true when expired but grace not yet over
* isBlocked bool — locked=true OR (unlicensed AND grace over) * isBlocked bool — locked=true OR unlicensed
* longOffline bool — no cloud contact for 7+ days (renewal needs it)
*/ */
export default function useLicenseStatus() { export default function useLicenseStatus() {
const { data } = useQuery({ const { data } = useQuery({
@@ -41,9 +48,13 @@ export default function useLicenseStatus() {
grace_expires_at: null, grace_expires_at: null,
grace_days_remaining: null, grace_days_remaining: null,
sync_failed: false, sync_failed: false,
offline_days: null,
license_problem: null,
grace_over: false,
showExpiryWarning: false, showExpiryWarning: false,
inGracePeriod: false, inGracePeriod: false,
isBlocked: false, isBlocked: false,
longOffline: false,
} }
} }
@@ -57,16 +68,21 @@ export default function useLicenseStatus() {
grace_expires_at = null, grace_expires_at = null,
grace_days_remaining = null, grace_days_remaining = null,
sync_failed = false, sync_failed = false,
offline_days = null,
license_problem = null,
grace_over = false,
} = data } = data
const showExpiryWarning = const showExpiryWarning =
days_until_expiry != null && days_until_expiry >= 0 && days_until_expiry <= 5 days_until_expiry != null && days_until_expiry >= 0 && days_until_expiry <= 14
const inGracePeriod = const inGracePeriod =
days_until_expiry != null && days_until_expiry < 0 && licensed days_until_expiry != null && days_until_expiry < 0 && licensed && !grace_over
const isBlocked = locked || !licensed const isBlocked = locked || !licensed
const longOffline = offline_days != null && offline_days >= 7
return { return {
licensed, licensed,
locked, locked,
@@ -77,8 +93,12 @@ export default function useLicenseStatus() {
grace_expires_at, grace_expires_at,
grace_days_remaining, grace_days_remaining,
sync_failed, sync_failed,
offline_days,
license_problem,
grace_over,
showExpiryWarning, showExpiryWarning,
inGracePeriod, inGracePeriod,
isBlocked, isBlocked,
longOffline,
} }
} }
+35 -4
View File
@@ -83,7 +83,10 @@ function PageTitle() {
function LicenseBanner({ license }) { function LicenseBanner({ license }) {
const { lock_reason, locked, lock_pending, days_until_expiry, expires_at, const { lock_reason, locked, lock_pending, days_until_expiry, expires_at,
grace_days_remaining, showExpiryWarning, inGracePeriod, isBlocked } = license grace_days_remaining, showExpiryWarning, inGracePeriod, isBlocked,
sync_failed, offline_days, longOffline } = license
// Renewal needs the cloud: say so whenever the site can't reach it right now
const connectHint = sync_failed ? ' Συνδέστε τον server στο internet για να ανανεωθεί.' : ''
function fmtDate(iso) { function fmtDate(iso) {
if (!iso) return '' if (!iso) return ''
@@ -103,12 +106,30 @@ function LicenseBanner({ license }) {
) )
} }
if (lock_reason === 'clock') {
return (
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
<ShieldAlert className="h-4 w-4 shrink-0" />
Η ώρα του server έχει γυρίσει πίσω. Διορθώστε την ημερομηνία/ώρα του server ή συνδέστε τον στο internet.
</div>
)
}
if (lock_reason === 'unverified') {
return (
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
<ShieldAlert className="h-4 w-4 shrink-0" />
Δεν υπάρχει επιβεβαιωμένη άδεια χρήσης. Συνδέστε τον server στο internet για να ενεργοποιηθεί.
</div>
)
}
if (isBlocked && lock_reason === 'expired') { if (isBlocked && lock_reason === 'expired') {
const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?' const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?'
return ( return (
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium"> <div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
<ShieldAlert className="h-4 w-4 shrink-0" /> <ShieldAlert className="h-4 w-4 shrink-0" />
Η άδεια χρήσης έληξε πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη. Η άδεια χρήσης έληξε πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.{connectHint}
</div> </div>
) )
} }
@@ -119,7 +140,7 @@ function LicenseBanner({ license }) {
return ( return (
<div className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-[13px] font-medium"> <div className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-[13px] font-medium">
<AlertTriangle className="h-4 w-4 shrink-0" /> <AlertTriangle className="h-4 w-4 shrink-0" />
Η άδεια χρήσης έληξε στις {fmtDate(expires_at)} (πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'}). Απομένουν {remaining} {remaining === 1 ? 'μέρα' : 'μέρες'} περιόδου χάριτος. Ανανεώστε την άδεια για να αποφύγετε το κλείδωμα. Η άδεια χρήσης έληξε στις {fmtDate(expires_at)} (πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'}). Απομένουν {remaining} {remaining === 1 ? 'μέρα' : 'μέρες'} περιόδου χάριτος. Ανανεώστε την άδεια για να αποφύγετε το κλείδωμα.{connectHint}
</div> </div>
) )
} }
@@ -129,7 +150,17 @@ function LicenseBanner({ license }) {
return ( return (
<div className="flex items-center gap-2 px-4 py-2 bg-amber-50 border-b border-amber-200 text-amber-700 text-[13px] font-medium"> <div className="flex items-center gap-2 px-4 py-2 bg-amber-50 border-b border-amber-200 text-amber-700 text-[13px] font-medium">
<AlertTriangle className="h-4 w-4 shrink-0" /> <AlertTriangle className="h-4 w-4 shrink-0" />
Η άδεια χρήσης λήγει σε {days} {days === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε έγκαιρα. Η άδεια χρήσης λήγει σε {days} {days === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε έγκαιρα.{connectHint}
</div>
)
}
// Informational: the license keeps working offline, but renewal needs the cloud
if (longOffline) {
return (
<div className="flex items-center gap-2 px-4 py-2 bg-slate-50 border-b border-slate-200 text-slate-600 text-[13px] font-medium">
<AlertTriangle className="h-4 w-4 shrink-0" />
Χωρίς σύνδεση με το cloud εδώ και {offline_days} μέρες. Το σύστημα λειτουργεί κανονικά — η άδεια ισχύει έως {fmtDate(expires_at)}.
</div> </div>
) )
} }