From 793bb8c366e91497f1d73d635da9225fb0a6a37e Mon Sep 17 00:00:00 2001 From: Z User Date: Thu, 29 Oct 2026 16:23:03 +0000 Subject: [PATCH] feat: instance dialog uses AllowedRepo selector (replaces gitRepo/gitBranch inputs) - Create Instance dialog now fetches repos from /api/aos/repos?companyId=X - Shows only repos the selected company has access to (public + assigned) - No more manual gitRepo/gitBranch inputs - Submit sends allowedRepoId instead of gitRepo/gitBranch - Product is derived from the selected repo (no manual input) - Helper text: 'Repos werden vom Admin freigegeben. Quelle: Gitea, Branch: main.' --- .zscripts/dev.pid | 2 +- src/components/aos/views/instances.tsx | 107 ++++++++----- src/lib/license-client/index.ts | 214 +++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 39 deletions(-) create mode 100755 src/lib/license-client/index.ts diff --git a/.zscripts/dev.pid b/.zscripts/dev.pid index 2b4ff56..bd9b507 100755 --- a/.zscripts/dev.pid +++ b/.zscripts/dev.pid @@ -1 +1 @@ -1114 +1242 diff --git a/src/components/aos/views/instances.tsx b/src/components/aos/views/instances.tsx index 06afbf8..fdbdcc3 100755 --- a/src/components/aos/views/instances.tsx +++ b/src/components/aos/views/instances.tsx @@ -175,6 +175,17 @@ interface InstanceStats { expiringSoon: number } +interface AllowedRepoLite { + id: string + giteaOwner: string + giteaRepo: string + displayName: string + description: string | null + product: string + isPublic: boolean + isActive: boolean +} + interface InstancesData { instances: Instance[] stats: InstanceStats @@ -1183,10 +1194,8 @@ function CreateInstanceDialog({ const [companyId, setCompanyId] = useState('') const [licenseId, setLicenseId] = useState('') - const [product, setProduct] = useState('restov1') + const [allowedRepoId, setAllowedRepoId] = useState('') const [plan, setPlan] = useState<'starter' | 'pro' | 'enterprise'>('pro') - const [gitRepo, setGitRepo] = useState('https://github.com/cetinmustafa83/restov1') - const [gitBranch, setGitBranch] = useState('main') const [isDemo, setIsDemo] = useState(true) const [saving, setSaving] = useState(false) @@ -1198,6 +1207,11 @@ function CreateInstanceDialog({ [crmData?.companies] ) + // Fetch repos available to the selected company + const { data: reposData } = useFetch<{ repos: AllowedRepoLite[] }>( + companyId ? `/api/aos/repos?companyId=${companyId}` : null + ) + // Filter licenses: same company, no existing live instance. const availableLicenses = useMemo(() => { const all = licensesData?.licenses ?? [] @@ -1220,22 +1234,28 @@ function CreateInstanceDialog({ if (availableLicenses.length === 0) setLicenseId('') }, [availableLicenses, licenseId]) + // Auto-select first available repo + useEffect(() => { + if (reposData?.repos?.[0] && !reposData.repos.find((r) => r.id === allowedRepoId)) { + setAllowedRepoId(reposData.repos[0].id) + } + if (!reposData?.repos?.length) setAllowedRepoId('') + }, [reposData, allowedRepoId]) + // Reset form when closing useEffect(() => { if (open) return setCompanyId('') setLicenseId('') - setProduct('restov1') + setAllowedRepoId('') setPlan('pro') - setGitRepo('https://github.com/cetinmustafa83/restov1') - setGitBranch('main') setIsDemo(true) }, [open]) const submit = async () => { if (!companyId) { toast.error('Bitte Firma auswählen'); return } if (!licenseId) { toast.error('Bitte Lizenz auswählen'); return } - if (!product.trim()) { toast.error('Produkt eingeben'); return } + if (!allowedRepoId) { toast.error('Bitte Produkt/Repo auswählen'); return } setSaving(true) const t = toast.loading('Instanz wird provisioniert (1–3 Min)…') try { @@ -1245,10 +1265,8 @@ function CreateInstanceDialog({ body: JSON.stringify({ companyId, licenseId, - product: product.trim(), + allowedRepoId, plan, - gitRepo: gitRepo.trim(), - gitBranch: gitBranch.trim(), isDemo, }), }) @@ -1267,6 +1285,8 @@ function CreateInstanceDialog({ } } + const availableRepos = reposData?.repos ?? [] + return ( @@ -1322,35 +1342,46 @@ function CreateInstanceDialog({

- {/* Produkt + Plan */} -
-
- - setProduct(e.target.value)} /> -
-
- - -
+ {/* Produkt/Repo (from AllowedRepo list) */} +
+ + +

+ Repos werden vom Admin freigegeben. Quelle: Gitea (git.dfmpanel.eu), Branch: main. +

- {/* Git */} -
-
- - setGitRepo(e.target.value)} /> -
-
- - setGitBranch(e.target.value)} /> -
+ {/* Plan */} +
+ +
{/* Demo Switch */} @@ -1369,7 +1400,7 @@ function CreateInstanceDialog({ - diff --git a/src/lib/license-client/index.ts b/src/lib/license-client/index.ts new file mode 100755 index 0000000..bc542c7 --- /dev/null +++ b/src/lib/license-client/index.ts @@ -0,0 +1,214 @@ +/** + * AOS License Server Client SDK + * ------------------------------ + * Drop-in client for product projects (restov1, aos-cms, wp-plugins, ...) + * to validate their license against the AOS license server. + * + * Usage in a Next.js / Node project: + * + * import { createLicenseClient } from '@/lib/license-client' + * + * const license = createLicenseClient({ + * serverUrl: process.env.AOS_LICENSE_SERVER_URL!, // https://aos.nordlicht.digital + * product: process.env.AOS_PRODUCT!, // 'restov1' + * key: process.env.AOS_LICENSE_KEY!, // 'AOS-PRO-RESTO-001-CORE' + * domain: process.env.AOS_LICENSE_DOMAIN, // optional, auto-detected + * offlineGraceHours: 72, // allow 72h offline (PRD: 14d) + * }) + * + * // On boot + every hour: + * const state = await license.check() + * if (state.features.blocked) { + * // Show "license suspended" banner, disable write features. + * } + * if (state.features.demoMode) { + * // Show demo watermark. + * } + * if (!license.hasModule('payment')) { + * // Hide payment UI. + * } + * + * Environment variables (no hardcoded secrets in code): + * AOS_LICENSE_SERVER_URL - license server base URL + * AOS_PRODUCT - product slug (must match server-side product) + * AOS_LICENSE_KEY - the license key issued by the agency + * AOS_LICENSE_DOMAIN - (optional) the domain this activation runs on + * + * The key is shipped via env, NOT in client-side code. For browser apps, + * the check must happen server-side (Next.js API route / middleware) so + * the key never reaches the browser. + */ + +export interface LicenseModuleState { + module: string + key: string + status: 'active' | 'suspended' | 'expired' | 'revoked' | 'demo' + environment: 'demo' | 'production' + validUntil: string | null +} + +export interface LicenseState { + valid: boolean + status: 'active' | 'suspended' | 'expired' | 'revoked' | 'demo' + environment: 'demo' | 'production' + product: string + plan: string + module: string | null + validUntil: string | null + suspendReason: string | null + maxActivations: number + features: { + enabled: boolean + demoMode: boolean + watermark: boolean + blocked: boolean + banner: 'license_suspended' | 'license_expired' | 'license_revoked' | 'license_demo' | null + } + modules: LicenseModuleState[] + checkedAt: string +} + +export interface LicenseClientOptions { + serverUrl: string + product: string + key: string + domain?: string + version?: string + offlineGraceHours?: number // default 72 + fetchImpl?: typeof fetch +} + +export interface LicenseClient { + check: (module?: string) => Promise + hasModule: (module: string) => boolean + isBlocked: () => boolean + isDemo: () => boolean + getState: () => LicenseState | null +} + +/** + * Create a license client. The client caches the last successful check + * (in-memory) and honors the offline grace period: if the server is + * unreachable, it falls back to the last known state for up to + * `offlineGraceHours` hours. + */ +export function createLicenseClient(opts: LicenseClientOptions): LicenseClient { + const fetchFn = opts.fetchImpl ?? fetch + const graceHours = opts.offlineGraceHours ?? 72 + + let cached: LicenseState | null = null + let lastCheckedAt: Date | null = null + + const check = async (module?: string): Promise => { + const body: Record = { key: opts.key } + if (module) body.module = module + if (opts.domain) body.domain = opts.domain + if (opts.version) body.version = opts.version + + try { + const res = await fetchFn(`${opts.serverUrl}/api/aos/licenses/check`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + // Non-200: try to fall back to cache, otherwise propagate. + if (cached && withinGrace(cached.checkedAt, graceHours)) { + return cached + } + throw new Error(`license check failed: HTTP ${res.status}`) + } + const state = (await res.json()) as LicenseState + // Product mismatch guard: if the server says a different product, treat + // the license as invalid for this deployment. + if (state.product && state.product !== opts.product) { + return { + ...state, + valid: false, + status: 'revoked', + features: { enabled: false, demoMode: false, watermark: false, blocked: true, banner: 'license_revoked' }, + suspendReason: `product mismatch: license is for "${state.product}", running "${opts.product}"`, + } + } + cached = state + lastCheckedAt = new Date(state.checkedAt) + return state + } catch (err) { + // Network error: fall back to cache if within grace period. + if (cached && withinGrace(cached.checkedAt, graceHours)) { + return cached + } + // No cache or cache expired: return a "blocked" state so the client + // app disables write features until the server is reachable again. + return { + valid: false, + status: 'suspended', + environment: 'production', + product: opts.product, + plan: 'unknown', + module: null, + validUntil: null, + suspendReason: `license server unreachable: ${err instanceof Error ? err.message : 'unknown'}`, + maxActivations: 0, + features: { + enabled: false, + demoMode: false, + watermark: false, + blocked: true, + banner: 'license_suspended', + }, + modules: [], + checkedAt: new Date().toISOString(), + } + } + } + + const hasModule = (module: string): boolean => { + if (!cached) return false + return cached.modules.some((m) => m.module === module && m.status === 'active') + } + + const isBlocked = (): boolean => cached?.features.blocked ?? true + const isDemo = (): boolean => cached?.features.demoMode ?? false + const getState = (): LicenseState | null => cached + + return { check, hasModule, isBlocked, isDemo, getState } +} + +function withinGrace(checkedAt: string, graceHours: number): boolean { + const age = Date.now() - new Date(checkedAt).getTime() + return age < graceHours * 3600 * 1000 +} + +/** + * Convenience: read the standard AOS_* env vars and return a configured client, + * or throw if required vars are missing. Use this in server-side code only. + * + * Required env: + * AOS_LICENSE_SERVER_URL + * AOS_PRODUCT + * AOS_LICENSE_KEY + * + * Optional env: + * AOS_LICENSE_DOMAIN + * AOS_OFFLINE_GRACE_HOURS (default 72) + */ +export function createLicenseClientFromEnv(): LicenseClient { + const serverUrl = process.env.AOS_LICENSE_SERVER_URL + const product = process.env.AOS_PRODUCT + const key = process.env.AOS_LICENSE_KEY + if (!serverUrl || !product || !key) { + throw new Error( + 'AOS license client requires AOS_LICENSE_SERVER_URL, AOS_PRODUCT, and AOS_LICENSE_KEY env vars' + ) + } + return createLicenseClient({ + serverUrl, + product, + key, + domain: process.env.AOS_LICENSE_DOMAIN, + offlineGraceHours: process.env.AOS_OFFLINE_GRACE_HOURS + ? Number(process.env.AOS_OFFLINE_GRACE_HOURS) + : 72, + }) +}