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.'
This commit is contained in:
1 parent
32a8b01845
commit
793bb8c366
3 files changed
+284
-39
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
1114
|
||||
1242
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
@@ -1322,35 +1342,46 @@ function CreateInstanceDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Produkt + Plan */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Produkt</Label>
|
||||
<Input value={product} onChange={(e) => setProduct(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Plan</Label>
|
||||
<Select value={plan} onValueChange={(v) => setPlan(v as 'starter' | 'pro' | 'enterprise')}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="starter">Starter</SelectItem>
|
||||
<SelectItem value="pro">Pro</SelectItem>
|
||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Produkt/Repo (from AllowedRepo list) */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Produkt / Repository</Label>
|
||||
<Select value={allowedRepoId} onValueChange={setAllowedRepoId} disabled={!companyId}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!companyId
|
||||
? 'Zuerst Firma wählen'
|
||||
: availableRepos.length === 0
|
||||
? 'Keine Repos verfügbar'
|
||||
: 'Produkt wählen'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableRepos.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.displayName}
|
||||
{r.description ? ` — ${r.description.slice(0, 50)}` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Repos werden vom Admin freigegeben. Quelle: Gitea (git.dfmpanel.eu), Branch: main.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Git */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Git Repo</Label>
|
||||
<Input value={gitRepo} onChange={(e) => setGitRepo(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Git Branch</Label>
|
||||
<Input value={gitBranch} onChange={(e) => setGitBranch(e.target.value)} />
|
||||
</div>
|
||||
{/* Plan */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Plan</Label>
|
||||
<Select value={plan} onValueChange={(v) => setPlan(v as 'starter' | 'pro' | 'enterprise')}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="starter">Starter</SelectItem>
|
||||
<SelectItem value="pro">Pro</SelectItem>
|
||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Demo Switch */}
|
||||
@@ -1369,7 +1400,7 @@ function CreateInstanceDialog({
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={saving || !licenseId}>
|
||||
<Button onClick={submit} disabled={saving || !licenseId || !allowedRepoId}>
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
Provisionieren
|
||||
</Button>
|
||||
|
||||
Executable
+214
@@ -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<LicenseState>
|
||||
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<LicenseState> => {
|
||||
const body: Record<string, string> = { 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,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user