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
+274
-29
No files matched your search
+1
-1
@@ -1 +1 @@
|
|||||||
1114
|
1242
|
||||||
@@ -175,6 +175,17 @@ interface InstanceStats {
|
|||||||
expiringSoon: number
|
expiringSoon: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AllowedRepoLite {
|
||||||
|
id: string
|
||||||
|
giteaOwner: string
|
||||||
|
giteaRepo: string
|
||||||
|
displayName: string
|
||||||
|
description: string | null
|
||||||
|
product: string
|
||||||
|
isPublic: boolean
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface InstancesData {
|
interface InstancesData {
|
||||||
instances: Instance[]
|
instances: Instance[]
|
||||||
stats: InstanceStats
|
stats: InstanceStats
|
||||||
@@ -1183,10 +1194,8 @@ function CreateInstanceDialog({
|
|||||||
|
|
||||||
const [companyId, setCompanyId] = useState('')
|
const [companyId, setCompanyId] = useState('')
|
||||||
const [licenseId, setLicenseId] = useState('')
|
const [licenseId, setLicenseId] = useState('')
|
||||||
const [product, setProduct] = useState('restov1')
|
const [allowedRepoId, setAllowedRepoId] = useState('')
|
||||||
const [plan, setPlan] = useState<'starter' | 'pro' | 'enterprise'>('pro')
|
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 [isDemo, setIsDemo] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
@@ -1198,6 +1207,11 @@ function CreateInstanceDialog({
|
|||||||
[crmData?.companies]
|
[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.
|
// Filter licenses: same company, no existing live instance.
|
||||||
const availableLicenses = useMemo(() => {
|
const availableLicenses = useMemo(() => {
|
||||||
const all = licensesData?.licenses ?? []
|
const all = licensesData?.licenses ?? []
|
||||||
@@ -1220,22 +1234,28 @@ function CreateInstanceDialog({
|
|||||||
if (availableLicenses.length === 0) setLicenseId('')
|
if (availableLicenses.length === 0) setLicenseId('')
|
||||||
}, [availableLicenses, licenseId])
|
}, [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
|
// Reset form when closing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) return
|
if (open) return
|
||||||
setCompanyId('')
|
setCompanyId('')
|
||||||
setLicenseId('')
|
setLicenseId('')
|
||||||
setProduct('restov1')
|
setAllowedRepoId('')
|
||||||
setPlan('pro')
|
setPlan('pro')
|
||||||
setGitRepo('https://github.com/cetinmustafa83/restov1')
|
|
||||||
setGitBranch('main')
|
|
||||||
setIsDemo(true)
|
setIsDemo(true)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!companyId) { toast.error('Bitte Firma auswählen'); return }
|
if (!companyId) { toast.error('Bitte Firma auswählen'); return }
|
||||||
if (!licenseId) { toast.error('Bitte Lizenz 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)
|
setSaving(true)
|
||||||
const t = toast.loading('Instanz wird provisioniert (1–3 Min)…')
|
const t = toast.loading('Instanz wird provisioniert (1–3 Min)…')
|
||||||
try {
|
try {
|
||||||
@@ -1245,10 +1265,8 @@ function CreateInstanceDialog({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
companyId,
|
companyId,
|
||||||
licenseId,
|
licenseId,
|
||||||
product: product.trim(),
|
allowedRepoId,
|
||||||
plan,
|
plan,
|
||||||
gitRepo: gitRepo.trim(),
|
|
||||||
gitBranch: gitBranch.trim(),
|
|
||||||
isDemo,
|
isDemo,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -1267,6 +1285,8 @@ function CreateInstanceDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const availableRepos = reposData?.repos ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
@@ -1322,12 +1342,36 @@ function CreateInstanceDialog({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Produkt + Plan */}
|
{/* Produkt/Repo (from AllowedRepo list) */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Produkt</Label>
|
<Label className="text-xs">Produkt / Repository</Label>
|
||||||
<Input value={product} onChange={(e) => setProduct(e.target.value)} />
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Plan */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Plan</Label>
|
<Label className="text-xs">Plan</Label>
|
||||||
<Select value={plan} onValueChange={(v) => setPlan(v as 'starter' | 'pro' | 'enterprise')}>
|
<Select value={plan} onValueChange={(v) => setPlan(v as 'starter' | 'pro' | 'enterprise')}>
|
||||||
@@ -1339,19 +1383,6 @@ function CreateInstanceDialog({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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 */}
|
{/* Demo Switch */}
|
||||||
<div className="flex items-start justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2.5">
|
<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}>
|
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</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" />}
|
{saving ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||||
Provisionieren
|
Provisionieren
|
||||||
</Button>
|
</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