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:
Z User committed 2026-10-29 16:29:28 +00:00
1 parent 32a8b01845
commit 793bb8c366
3 files changed
+274 -29

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1114
1242
+59 -28
View File
@@ -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,12 +1342,36 @@ function CreateInstanceDialog({
</p>
</div>
{/* Produkt + Plan */}
<div className="grid grid-cols-2 gap-3">
{/* Produkt/Repo (from AllowedRepo list) */}
<div className="space-y-1.5">
<Label className="text-xs">Produkt</Label>
<Input value={product} onChange={(e) => setProduct(e.target.value)} />
<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>
{/* Plan */}
<div className="space-y-1.5">
<Label className="text-xs">Plan</Label>
<Select value={plan} onValueChange={(v) => setPlan(v as 'starter' | 'pro' | 'enterprise')}>
@@ -1339,19 +1383,6 @@ function CreateInstanceDialog({
</SelectContent>
</Select>
</div>
</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>
</div>
{/* Demo Switch */}
<div className="flex items-start justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2.5">
@@ -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>
+214
View File
@@ -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,
})
}