first commit

This commit is contained in:
js 2026-05-22 12:29:17 +00:00
parent fb7283e4e1
commit 9c1d1e1782
74 changed files with 6107 additions and 241 deletions

View File

96
cloud_portal/api/auth.py Normal file
View File

@ -0,0 +1,96 @@
import frappe
from frappe import _
from frappe.rate_limiter import rate_limit
@frappe.whitelist(allow_guest=True)
@rate_limit(limit=5, seconds=300) # 5 attempts per 5 minutes
def register_user(email, password, full_name, voen=None):
"""
Register a new user for Cloud Portal.
Creates a user with Website User role.
"""
# Validate inputs
if not email or not password or not full_name:
return {
"success": False,
"message": _("Please provide email, password and full name")
}
# Validate input lengths to prevent abuse
email = email.strip()[:254] # Max email length per RFC
full_name = full_name.strip()[:140] # Reasonable name length
# Check if email is valid
if not frappe.utils.validate_email_address(email):
return {
"success": False,
"message": _("Please enter a valid email address")
}
# Check if user already exists
if frappe.db.exists("User", email):
return {
"success": False,
"message": _("User with this email already exists")
}
# Password will be validated by Frappe's password policy
try:
# Parse full name once for efficiency
name_parts = full_name.split()
first_name = name_parts[0] if name_parts else email
last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
# Create user
user = frappe.get_doc({
"doctype": "User",
"email": email,
"first_name": first_name,
"last_name": last_name,
"enabled": 1,
"new_password": password,
"send_welcome_email": 0,
"user_type": "Website User"
})
user.flags.ignore_permissions = True
user.insert()
# Add Website User role
user.add_roles("Website User")
# Create ERPNext Customer linked to this user
customer = frappe.get_doc({
"doctype": "Customer",
"customer_name": full_name,
"customer_type": "Individual",
"tax_id": voen or "",
"portal_users": [{"user": email}],
})
customer.flags.ignore_permissions = True
customer.insert()
frappe.db.commit()
# Auto-login after registration
frappe.local.login_manager.login_as(email)
return {
"success": True,
"message": _("Account created successfully"),
"user": email
}
except frappe.exceptions.ValidationError as e:
return {
"success": False,
"message": str(e)
}
except Exception as e:
frappe.log_error(f"Registration error: {str(e)}", "Cloud Portal Registration")
return {
"success": False,
"message": _("Registration failed. Please try again.")
}

View File

@ -0,0 +1,369 @@
"""
Sync customer subscriptions between local ERPNext and remote ERPNext.
Configuration (site_config.json keys):
erpnext_sync_url base URL of remote ERPNext, e.g. https://api.host.jeyerp.az
erpnext_sync_api_key API key of remote ERPNext user
erpnext_sync_api_secret API secret of remote ERPNext user
"""
import frappe
import requests
from frappe import _
from urllib.parse import quote as url_quote
# ---------------------------------------------------------------------------
# Config / session helpers
# ---------------------------------------------------------------------------
def _get_config():
conf = frappe.conf
return {
"base_url": conf.get("erpnext_sync_url", ""),
"api_key": conf.get("erpnext_sync_api_key", ""),
"api_secret": conf.get("erpnext_sync_api_secret", ""),
# Company name in remote ERPNext (may differ from local)
"remote_company": conf.get("erpnext_sync_company", ""),
}
def _get_session():
"""Return (requests.Session, base_url, config) or (None, None, None) if not configured."""
config = _get_config()
if not config["base_url"] or not config["api_key"] or not config["api_secret"]:
return None, None, None
session = requests.Session()
session.auth = (config["api_key"], config["api_secret"])
session.headers.update({"Content-Type": "application/json"})
return session, config["base_url"].rstrip("/"), config
def _get_remote_company(session, base_url, config):
"""Return the company name to use in the remote ERPNext."""
if config.get("remote_company"):
return config["remote_company"]
# Auto-detect from remote Global Defaults
try:
resp = session.get(
f"{base_url}/api/method/frappe.client.get_value",
params={"doctype": "Global Defaults", "fieldname": "default_company"},
timeout=10,
)
resp.raise_for_status()
company = resp.json().get("message", {}).get("default_company", "")
if company:
frappe.logger().info(f"Auto-detected remote company: {company}")
return company
except Exception as e:
frappe.log_error(f"Could not detect remote company: {e}", "Subscription Sync")
return ""
# ---------------------------------------------------------------------------
# Core sync functions
# ---------------------------------------------------------------------------
def push_subscription_to_remote(local_sub_name):
"""
Create a mirror of the local Subscription in remote ERPNext.
Returns the remote subscription name (str) on success, None on failure.
"""
session, base_url, config = _get_session()
if not session:
frappe.log_error("ERPNext sync credentials not configured in site_config", "Subscription Sync")
return None
try:
sub = frappe.get_doc("Subscription", local_sub_name)
except Exception as e:
frappe.log_error(f"Cannot load local subscription {local_sub_name}: {e}", "Subscription Sync")
return None
plans = [{"plan": p.plan, "qty": p.qty} for p in (sub.plans or [])]
if not plans:
frappe.log_error(f"Subscription {local_sub_name} has no plans skipping push", "Subscription Sync")
return None
remote_company = _get_remote_company(session, base_url, config)
if not remote_company:
frappe.log_error("Cannot determine remote company name", "Subscription Sync")
return None
# Ensure the customer exists in remote before creating the subscription
if not ensure_customer_in_remote(sub.party):
frappe.log_error(
f"Cannot push subscription {local_sub_name}: customer '{sub.party}' not found in remote and could not be created",
"Subscription Sync"
)
return None
payload = {
"party_type": sub.party_type or "Customer",
"party": sub.party,
"start_date": str(sub.start_date) if sub.start_date else frappe.utils.today(),
"generate_invoice_at": sub.generate_invoice_at or "End of the current subscription period",
"plans": plans,
"company": remote_company,
}
if sub.trial_period_start:
payload["trial_period_start"] = str(sub.trial_period_start)
if sub.trial_period_end:
payload["trial_period_end"] = str(sub.trial_period_end)
try:
resp = session.post(
f"{base_url}/api/resource/Subscription",
json=payload,
timeout=15,
)
resp.raise_for_status()
remote_name = resp.json().get("data", {}).get("name")
if remote_name:
frappe.logger().info(f"Pushed subscription {local_sub_name} → remote {remote_name}")
return remote_name
except requests.HTTPError as e:
body = ""
try:
body = e.response.text[:500]
except Exception:
pass
frappe.log_error(
f"HTTP error pushing subscription {local_sub_name}: {e}\n{body}",
"Subscription Sync"
)
return None
except Exception as e:
frappe.log_error(f"Error pushing subscription {local_sub_name}: {e}", "Subscription Sync")
return None
def pull_subscription_status(local_sub_name, remote_sub_name):
"""
Fetch the subscription status from remote ERPNext and update local if changed.
Returns False if the remote subscription no longer exists (caller should re-push).
"""
session, base_url, _ = _get_session()
if not session:
return True
try:
resp = session.get(
f"{base_url}/api/resource/Subscription/{remote_sub_name}",
timeout=10,
)
if resp.status_code == 404:
# Remote subscription was deleted signal caller to re-push
return False
resp.raise_for_status()
remote_status = resp.json().get("data", {}).get("status")
if not remote_status:
return True
local_status = frappe.db.get_value("Subscription", local_sub_name, "status")
if remote_status != local_status:
frappe.db.set_value("Subscription", local_sub_name, "status", remote_status)
frappe.db.commit()
frappe.logger().info(
f"Subscription {local_sub_name} status updated: {local_status}{remote_status}"
)
return True
except Exception as e:
frappe.log_error(
f"Error pulling status for {local_sub_name} (remote: {remote_sub_name}): {e}",
"Subscription Sync"
)
return True
# ---------------------------------------------------------------------------
# Customer sync
# ---------------------------------------------------------------------------
def ensure_customer_in_remote(customer_name):
"""
Ensure the Customer exists in remote ERPNext.
Creates it if missing. Returns True on success (exists or created), False on failure.
"""
session, base_url, _ = _get_session()
if not session:
return False
# Check if already exists
try:
resp = session.get(
f"{base_url}/api/resource/Customer/{url_quote(customer_name)}",
timeout=10,
)
if resp.status_code == 200:
return True # Already exists
except Exception:
pass
# Create customer in remote
try:
customer = frappe.get_doc("Customer", customer_name)
payload = {
"customer_name": customer.customer_name,
"customer_type": customer.customer_type or "Individual",
}
if customer.tax_id:
payload["tax_id"] = customer.tax_id
resp = session.post(
f"{base_url}/api/resource/Customer",
json=payload,
timeout=15,
)
resp.raise_for_status()
frappe.logger().info(f"Created customer '{customer_name}' in remote ERPNext")
return True
except Exception as e:
frappe.log_error(f"Failed to create customer '{customer_name}' in remote: {e}", "Subscription Sync")
return False
# ---------------------------------------------------------------------------
# DocType event hooks
# ---------------------------------------------------------------------------
def on_customer_insert(doc, method=None):
"""Frappe hook: sync new Customer to remote ERPNext after insert."""
frappe.enqueue(
"cloud_portal.api.erpnext_sync.ensure_customer_in_remote",
queue="short",
timeout=30,
customer_name=doc.name,
)
def on_subscription_insert(doc, method=None):
"""Frappe hook: sync new Subscription to remote ERPNext after insert."""
frappe.enqueue(
"cloud_portal.api.erpnext_sync.push_and_store_remote_subscription_by_name",
queue="default",
timeout=60,
local_sub_name=doc.name,
)
# ---------------------------------------------------------------------------
# Background job helpers
# ---------------------------------------------------------------------------
def push_and_store_remote_subscription(container_name, local_sub_name):
"""
Background job: push a newly created subscription to remote ERPNext
and store the remote ID on both the Cloud Container and the Subscription.
"""
remote_sub_name = push_subscription_to_remote(local_sub_name)
if remote_sub_name:
frappe.db.set_value("Cloud Container", container_name, "remote_subscription", remote_sub_name)
_store_remote_sub_on_subscription(local_sub_name, remote_sub_name)
frappe.db.commit()
def push_and_store_remote_subscription_by_name(local_sub_name):
"""
Background job (called from on_subscription_insert hook):
push a newly created subscription to remote ERPNext and store the remote ID.
Skips if already synced.
"""
# Avoid double-push if already handled by push_and_store_remote_subscription
existing = frappe.db.get_value("Subscription", local_sub_name, "custom_remote_sub_id")
if existing:
return
remote_sub_name = push_subscription_to_remote(local_sub_name)
if remote_sub_name:
_store_remote_sub_on_subscription(local_sub_name, remote_sub_name)
# Also update Cloud Container if linked
container = frappe.db.get_value("Cloud Container", {"subscription": local_sub_name}, "name")
if container:
frappe.db.set_value("Cloud Container", container, "remote_subscription", remote_sub_name)
frappe.db.commit()
def _store_remote_sub_on_subscription(local_sub_name, remote_sub_name):
"""Store the remote subscription ID in a custom field on the local Subscription."""
try:
frappe.db.set_value("Subscription", local_sub_name, "custom_remote_sub_id", remote_sub_name)
except Exception:
# Custom field might not exist yet — safe to ignore, Container field is the fallback
pass
# ---------------------------------------------------------------------------
# Scheduled job
# ---------------------------------------------------------------------------
def sync_all_subscriptions():
"""
Scheduled job: push any un-synced subscriptions and pull status updates
from remote ERPNext for all already-synced subscriptions.
Covers both container-linked and standalone subscriptions.
"""
session, _, __ = _get_session()
if not session:
return
# Build a map of all local subscriptions with their remote IDs
# Source 1: Cloud Container records
containers = frappe.get_all(
"Cloud Container",
filters=[["subscription", "is", "set"]],
fields=["name", "subscription", "remote_subscription"],
)
container_by_sub = {c["subscription"]: c for c in containers}
# Source 2: All local subscriptions (catches ones not linked to a container)
all_subs = frappe.get_all(
"Subscription",
fields=["name", "custom_remote_sub_id"],
)
pushed = 0
pulled = 0
for sub in all_subs:
local_sub = sub["name"]
# Prefer remote ID from the subscription's own field; fall back to container
container = container_by_sub.get(local_sub)
remote_sub = sub.get("custom_remote_sub_id") or (container or {}).get("remote_subscription")
# Backfill custom_remote_sub_id from container if missing
if remote_sub and not sub.get("custom_remote_sub_id"):
_store_remote_sub_on_subscription(local_sub, remote_sub)
frappe.db.commit()
# Push if not yet synced
if not remote_sub:
remote_sub = push_subscription_to_remote(local_sub)
if remote_sub:
_store_remote_sub_on_subscription(local_sub, remote_sub)
if container:
frappe.db.set_value("Cloud Container", container["name"], "remote_subscription", remote_sub)
frappe.db.commit()
pushed += 1
# Pull status; if deleted remotely, clear and re-push next cycle
if remote_sub:
still_exists = pull_subscription_status(local_sub, remote_sub)
if not still_exists:
_store_remote_sub_on_subscription(local_sub, None)
if container:
frappe.db.set_value("Cloud Container", container["name"], "remote_subscription", None)
frappe.db.commit()
else:
pulled += 1
if pushed or pulled:
frappe.logger().info(
f"Subscription sync complete: pushed={pushed}, status_pulls={pulled}"
)

61
cloud_portal/api/voen.py Normal file
View File

@ -0,0 +1,61 @@
import uuid
import frappe
import requests
BASE_URL = "https://new.e-taxes.gov.az/api/po/authless/public/v1/authless"
SERVICE_CODE = "findTaxpayerByTinAndName"
@frappe.whitelist(allow_guest=True)
def lookup(voen):
"""Lookup organization info by VOEN (TIN) from Azerbaijan e-taxes portal."""
voen = str(voen).strip()
if not voen:
frappe.throw("VOEN is required")
try:
# Client-generated session ID (same for all requests)
session_id = str(uuid.uuid4())
# Step 1: register service usage, get serviceUsageId
r1 = requests.post(
f"{BASE_URL}/informational-service-usage",
json={"serviceCode": SERVICE_CODE, "status": "provided", "loginMethod": "unregistered"},
headers={"sessionId": session_id},
timeout=10,
)
r1.raise_for_status()
# Step 2: find taxpayer by TIN
r2 = requests.post(
f"{BASE_URL}/findTaxpayer",
json={"middleName": None, "type": "legalEntity", "tin": voen, "serviceCode": SERVICE_CODE},
headers={"sessionId": session_id},
timeout=10,
)
r2.raise_for_status()
data = r2.json()
taxpayers = data.get("taxpayers", [])
if not taxpayers:
return {"success": False, "message": "Organization not found for this VOEN"}
tp = taxpayers[0]
legal = tp.get("legalTaxpayerStatus", {})
return {
"success": True,
"name": tp.get("name", ""),
"tin": tp.get("tin", ""),
"active": tp.get("active", False),
"vat_payer": tp.get("vatPayer", False),
"organization_type": tp.get("organizationType", ""),
"legal_address": legal.get("legalAddress", ""),
"director": legal.get("legitimate", ""),
}
except requests.exceptions.Timeout:
frappe.throw("Request to e-taxes.gov.az timed out. Please try again.")
except requests.exceptions.RequestException as e:
frappe.log_error(f"VOEN lookup error: {e}", "VOEN Lookup")
frappe.throw("Failed to connect to e-taxes.gov.az. Please try again.")

View File

@ -0,0 +1,165 @@
// Copyright (c) 2025, Cloud Portal and contributors
// For license information, please see license.txt
frappe.ui.form.on("Cloud Container", {
refresh(frm) {
// Add status indicator
if (frm.doc.status) {
let indicator = "grey";
if (frm.doc.status === "Running") indicator = "green";
else if (frm.doc.status === "Stopped") indicator = "orange";
else if (frm.doc.status === "Error") indicator = "red";
else if (frm.doc.status === "Creating") indicator = "blue";
frm.page.set_indicator(frm.doc.status, indicator);
}
// Add action buttons for existing containers
if (!frm.is_new() && frm.doc.status !== "Pending" && frm.doc.status !== "Creating") {
// Start button
if (frm.doc.status === "Stopped" || frm.doc.status === "Error") {
frm.add_custom_button(__("Start"), function() {
frappe.confirm(
__("Start container {0}?", [frm.doc.container_name]),
function() {
frappe.call({
method: "cloud_portal.cloud_portal.doctype.cloud_container.cloud_container.start_user_container",
args: { name: frm.doc.name },
freeze: true,
freeze_message: __("Starting container..."),
callback: function(r) {
if (r.message && r.message.success) {
frappe.show_alert({
message: __("Container started"),
indicator: "green"
});
frm.reload_doc();
} else {
frappe.msgprint({
title: __("Error"),
message: r.message ? r.message.error : __("Failed to start"),
indicator: "red"
});
}
}
});
}
);
}, __("Actions"));
}
// Stop button
if (frm.doc.status === "Running") {
frm.add_custom_button(__("Stop"), function() {
frappe.confirm(
__("Stop container {0}?", [frm.doc.container_name]),
function() {
frappe.call({
method: "cloud_portal.cloud_portal.doctype.cloud_container.cloud_container.stop_user_container",
args: { name: frm.doc.name },
freeze: true,
freeze_message: __("Stopping container..."),
callback: function(r) {
if (r.message && r.message.success) {
frappe.show_alert({
message: __("Container stopped"),
indicator: "orange"
});
frm.reload_doc();
} else {
frappe.msgprint({
title: __("Error"),
message: r.message ? r.message.error : __("Failed to stop"),
indicator: "red"
});
}
}
});
}
);
}, __("Actions"));
}
// Sync status button
frm.add_custom_button(__("Sync Status"), function() {
frappe.call({
method: "cloud_portal.cloud_portal.doctype.cloud_container.cloud_container.sync_container_status",
args: { name: frm.doc.name },
freeze: true,
freeze_message: __("Syncing status..."),
callback: function(r) {
if (r.message && r.message.success) {
frappe.show_alert({
message: __("Status synced: {0}", [r.message.status]),
indicator: "green"
});
frm.reload_doc();
} else {
frappe.msgprint({
title: __("Error"),
message: r.message ? r.message.error : __("Failed to sync"),
indicator: "red"
});
}
}
});
}, __("Actions"));
// Delete button
frm.add_custom_button(__("Delete Container"), function() {
frappe.confirm(
__("Are you sure you want to delete container {0}? This action cannot be undone.", [frm.doc.container_name]),
function() {
frappe.call({
method: "cloud_portal.cloud_portal.doctype.cloud_container.cloud_container.delete_user_container",
args: { name: frm.doc.name },
freeze: true,
freeze_message: __("Deleting container..."),
callback: function(r) {
if (r.message && r.message.success) {
frappe.show_alert({
message: __("Container deleted"),
indicator: "green"
});
frappe.set_route("List", "Cloud Container");
} else {
frappe.msgprint({
title: __("Error"),
message: r.message ? r.message.error : __("Failed to delete"),
indicator: "red"
});
}
}
});
}
);
}, __("Actions"));
}
// Auto-refresh while creating
if (frm.doc.status === "Creating") {
frm.dashboard.add_comment(__("Container is being created. This page will refresh automatically."), "blue", true);
setTimeout(function() {
frm.reload_doc();
}, 5000);
}
},
container_name(frm) {
// Validate container name format
let name = frm.doc.container_name;
if (name) {
// Only allow lowercase letters, numbers, and hyphens
let clean_name = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/--+/g, "-");
if (clean_name !== name) {
frm.set_value("container_name", clean_name);
frappe.show_alert({
message: __("Container name cleaned: only lowercase letters, numbers, and hyphens allowed"),
indicator: "orange"
});
}
}
}
});

View File

@ -0,0 +1,217 @@
{
"actions": [],
"allow_rename": 0,
"autoname": "format:{owner}-{container_name}",
"creation": "2025-01-22 12:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"container_name",
"image",
"container_type",
"column_break_config",
"incus_server",
"section_break_status",
"status",
"incus_status",
"column_break_status",
"ip_address",
"architecture",
"section_break_details",
"created_at",
"last_used_at",
"column_break_details",
"error_message",
"section_break_subscription",
"max_users",
"trial",
"column_break_subscription",
"subscription",
"remote_subscription"
],
"fields": [
{
"fieldname": "container_name",
"fieldtype": "Data",
"label": "Container Name",
"reqd": 1,
"unique": 1,
"description": "Unique container name in Incus"
},
{
"fieldname": "image",
"fieldtype": "Data",
"label": "Image",
"reqd": 1,
"default": "alpine/edge",
"description": "Incus image alias (e.g., alpine/edge, ubuntu/22.04)"
},
{
"fieldname": "container_type",
"fieldtype": "Select",
"label": "Type",
"options": "container\nvirtual-machine",
"default": "container",
"description": "Instance type: container or virtual machine"
},
{
"fieldname": "column_break_config",
"fieldtype": "Column Break"
},
{
"fieldname": "incus_server",
"fieldtype": "Data",
"label": "Incus Server",
"default": "default",
"read_only": 1,
"description": "Incus server identifier"
},
{
"fieldname": "section_break_status",
"fieldtype": "Section Break",
"label": "Status"
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "Pending\nCreating\nRunning\nStopped\nError",
"default": "Pending",
"read_only": 1,
"in_list_view": 1,
"description": "Current container status"
},
{
"fieldname": "incus_status",
"fieldtype": "Data",
"label": "Incus Status",
"read_only": 1,
"description": "Raw status from Incus API"
},
{
"fieldname": "column_break_status",
"fieldtype": "Column Break"
},
{
"fieldname": "ip_address",
"fieldtype": "Data",
"label": "IP Address",
"read_only": 1,
"in_list_view": 1,
"description": "Container IP address"
},
{
"fieldname": "architecture",
"fieldtype": "Data",
"label": "Architecture",
"read_only": 1,
"description": "Container architecture (x86_64, aarch64, etc.)"
},
{
"fieldname": "section_break_details",
"fieldtype": "Section Break",
"label": "Details"
},
{
"fieldname": "created_at",
"fieldtype": "Datetime",
"label": "Created At",
"read_only": 1,
"description": "Container creation time in Incus"
},
{
"fieldname": "last_used_at",
"fieldtype": "Datetime",
"label": "Last Used At",
"read_only": 1,
"description": "Last usage time"
},
{
"fieldname": "column_break_details",
"fieldtype": "Column Break"
},
{
"fieldname": "error_message",
"fieldtype": "Small Text",
"label": "Error Message",
"read_only": 1,
"description": "Error details if creation failed"
},
{
"fieldname": "section_break_subscription",
"fieldtype": "Section Break",
"label": "Subscription"
},
{
"fieldname": "max_users",
"fieldtype": "Int",
"label": "Max Users",
"default": "1",
"description": "Number of licensed users for this container"
},
{
"fieldname": "trial",
"fieldtype": "Check",
"label": "Trial Mode",
"default": "0",
"description": "Trial / demo mode — fixed 1 user, 14-day period"
},
{
"fieldname": "column_break_subscription",
"fieldtype": "Column Break"
},
{
"fieldname": "subscription",
"fieldtype": "Link",
"label": "Subscription",
"options": "Subscription",
"read_only": 1,
"description": "Linked ERPNext Subscription"
},
{
"fieldname": "remote_subscription",
"fieldtype": "Data",
"label": "Remote Subscription",
"read_only": 1,
"description": "Subscription ID in remote ERPNext (synced)"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-22 12:00:00.000000",
"modified_by": "Administrator",
"module": "Cloud Portal",
"name": "Cloud Container",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Website User",
"write": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@ -0,0 +1,344 @@
import re
import frappe
from frappe.model.document import Document
from frappe import _
# Import Incus API functions from incus_manager
try:
from incus_manager.incus_manager.doctype.incus_container.incus_container import (
create_container as incus_create_container,
list_containers as incus_list_containers,
)
INCUS_AVAILABLE = True
except ImportError:
INCUS_AVAILABLE = False
# Container name validation: lowercase letters, numbers, hyphens, 3-63 chars, starts with letter
CONTAINER_NAME_PATTERN = re.compile(r'^[a-z][a-z0-9-]{2,62}$')
class CloudContainer(Document):
"""DocType for managing user cloud containers"""
def validate(self):
"""Validate container data before save"""
self.validate_container_name()
def validate_container_name(self):
"""Validate container name format"""
if not self.container_name:
frappe.throw(_("Container name is required"))
if not CONTAINER_NAME_PATTERN.match(self.container_name):
frappe.throw(
_("Container name must be 3-63 characters, start with a letter, "
"and contain only lowercase letters, numbers, and hyphens")
)
def before_insert(self):
"""Set owner to current user before insert"""
if not self.owner:
self.owner = frappe.session.user
def after_insert(self):
"""Queue container creation in Incus after document is saved"""
if self.status == "Pending":
frappe.enqueue(
"cloud_portal.cloud_portal.doctype.cloud_container.cloud_container.create_incus_container",
queue="default",
timeout=300,
doc_name=self.name,
container_name=self.container_name,
image=self.image,
container_type=self.container_type
)
self.db_set("status", "Creating")
def create_incus_container(doc_name, container_name, image, container_type):
"""Background job to create container in Incus"""
# Get owner for realtime notification
owner = frappe.db.get_value("Cloud Container", doc_name, "owner")
if not INCUS_AVAILABLE:
frappe.db.set_value("Cloud Container", doc_name, {
"status": "Error",
"error_message": "Incus Manager module not available"
})
frappe.db.commit()
_notify_user(owner)
return
try:
# Send request without waiting - trust it will be created
result = incus_create_container(
name=container_name,
image_alias=image,
container_type=container_type,
wait_completion=False # Don't wait - avoid round-robin issues
)
if result.get("success") or result.get("operation_url"):
# Request accepted - consider success, status will be updated during sync
frappe.db.set_value("Cloud Container", doc_name, {
"status": "Running", # Optimistically set to Running
"incus_status": "Created",
"error_message": ""
})
else:
frappe.db.set_value("Cloud Container", doc_name, {
"status": "Error",
"error_message": result.get("message", result.get("error", "Unknown error"))
})
frappe.db.commit()
_notify_user(owner)
except Exception as e:
frappe.db.set_value("Cloud Container", doc_name, {
"status": "Error",
"error_message": str(e)
})
frappe.db.commit()
_notify_user(owner)
def _notify_user(user):
"""Send realtime notification to user about container list update"""
if user:
frappe.publish_realtime(
"list_update",
{"doctype": "Cloud Container"},
user=user
)
@frappe.whitelist()
def get_user_containers():
"""
Get containers for current user only.
Explicit owner filter ensures users (including admins) only see their own containers.
"""
return frappe.get_all(
"Cloud Container",
filters={"owner": frappe.session.user},
fields=["name", "container_name", "image", "container_type",
"status", "incus_status", "ip_address", "creation"],
order_by="creation desc"
)
@frappe.whitelist()
def create_user_container(container_name, image="alpine/edge", container_type="container",
max_users=1, trial=0):
"""
Create a new container for current user.
Validation is handled by DocType's validate() method.
Permissions are handled by DocType permissions (Website User with if_owner).
"""
max_users = int(max_users)
trial = int(trial)
doc = frappe.get_doc({
"doctype": "Cloud Container",
"container_name": container_name,
"image": image,
"container_type": container_type,
"status": "Pending",
"max_users": 1 if trial else max_users,
"trial": trial,
})
doc.insert()
# Create ERPNext Subscription
subscription_name = _create_subscription(doc, max_users=1 if trial else max_users, trial=trial)
if subscription_name:
frappe.db.set_value("Cloud Container", doc.name, "subscription", subscription_name)
frappe.db.commit()
# Push subscription to remote ERPNext (async to avoid blocking the user)
frappe.enqueue(
"cloud_portal.api.erpnext_sync.push_and_store_remote_subscription",
queue="default",
timeout=30,
container_name=doc.name,
local_sub_name=subscription_name,
)
return {
"success": True,
"name": doc.name,
"container_name": doc.container_name,
"message": _("Container creation started")
}
# ---------------------------------------------------------------------------
# Subscription helpers
# ---------------------------------------------------------------------------
PLAN_NAME = "Cloud Container - Per User"
ITEM_CODE = "Cloud Container User License"
def _create_subscription(container_doc, max_users, trial):
"""Create an ERPNext Subscription linked to the current user's Customer."""
user = frappe.session.user
customer = frappe.db.get_value("Portal User", {"user": user}, "parent")
if not customer:
frappe.log_error(f"No customer found for user {user}", "Subscription Creation")
return None
plan = _get_or_create_subscription_plan()
if not plan:
return None
today = frappe.utils.today()
company = frappe.db.get_single_value("Global Defaults", "default_company")
sub = frappe.get_doc({
"doctype": "Subscription",
"party_type": "Customer",
"party": customer,
"start_date": today,
"generate_invoice_at": "End of the current subscription period",
"plans": [{"plan": plan, "qty": max_users}],
"company": company,
})
if trial:
sub.trial_period_start = today
sub.trial_period_end = frappe.utils.add_days(today, 14)
try:
sub.flags.ignore_permissions = True
sub.insert()
frappe.db.commit()
return sub.name
except Exception as e:
frappe.log_error(f"Failed to create subscription: {e}", "Subscription Creation")
return None
def _get_or_create_subscription_plan():
"""Get or create the per-user monthly subscription plan."""
if frappe.db.exists("Subscription Plan", PLAN_NAME):
return PLAN_NAME
item = _get_or_create_service_item()
if not item:
return None
currency = frappe.db.get_single_value("Global Defaults", "default_currency") or "USD"
try:
plan = frappe.get_doc({
"doctype": "Subscription Plan",
"plan_name": PLAN_NAME,
"item": item,
"currency": currency,
"price_determination": "Fixed Rate",
"cost": 0,
"billing_interval": "Month",
"billing_interval_count": 1,
})
plan.flags.ignore_permissions = True
plan.insert()
frappe.db.commit()
return PLAN_NAME
except Exception as e:
frappe.log_error(f"Failed to create subscription plan: {e}", "Subscription Setup")
return None
def _get_or_create_service_item():
"""Get or create the service Item for user licenses."""
if frappe.db.exists("Item", ITEM_CODE):
return ITEM_CODE
item_group = next(
(g for g in ["Services", "All Item Groups", "Products"]
if frappe.db.exists("Item Group", g)),
None
)
if not item_group:
frappe.log_error("No suitable Item Group found", "Subscription Setup")
return None
try:
item = frappe.get_doc({
"doctype": "Item",
"item_code": ITEM_CODE,
"item_name": ITEM_CODE,
"item_group": item_group,
"is_sales_item": 1,
"is_stock_item": 0,
})
item.flags.ignore_permissions = True
item.insert()
frappe.db.commit()
return ITEM_CODE
except Exception as e:
frappe.log_error(f"Failed to create item: {e}", "Subscription Setup")
return None
def sync_all_container_statuses():
"""Scheduled job: Sync all container statuses from Incus."""
if not INCUS_AVAILABLE:
return
try:
# Get container list from Incus
result = incus_list_containers()
if not result.get("success"):
frappe.log_error(f"Failed to list containers: {result.get('error')}", "Container Sync")
return
incus_containers = {c["name"]: c for c in result.get("containers", [])}
# Get all containers from DocType
cloud_containers = frappe.get_all(
"Cloud Container",
filters={"status": ["not in", ["Error", "Pending"]]},
fields=["name", "container_name", "status", "owner"]
)
# Map Incus status to Cloud Container status
status_map = {"running": "Running", "stopped": "Stopped"}
updated_owners = set()
for cc in cloud_containers:
container_name = cc["container_name"]
incus_data = incus_containers.get(container_name)
if not incus_data:
continue
incus_status = incus_data.get("status", "").lower()
new_status = status_map.get(incus_status, cc["status"])
# Only update if status changed
if new_status != cc["status"]:
frappe.db.set_value("Cloud Container", cc["name"], {
"status": new_status,
"incus_status": incus_data.get("status"),
"architecture": incus_data.get("architecture")
})
updated_owners.add(cc["owner"])
frappe.db.commit()
# Notify users about updates via realtime
for owner in updated_owners:
frappe.publish_realtime(
"list_update",
{"doctype": "Cloud Container"},
user=owner
)
except Exception as e:
frappe.log_error(f"Container sync error: {str(e)}", "Container Sync")

View File

@ -5,248 +5,38 @@ app_description = "Cloud Portal"
app_email = "ibo131205@gmail.com" app_email = "ibo131205@gmail.com"
app_license = "mit" app_license = "mit"
# Apps # Website Routes
# ------------------ website_route_rules = [
{"from_route": "/portal/<path:app_path>", "to_route": "portal"},
]
# required_apps = [] # DocType Event Hooks
doc_events = {
"Customer": {
"after_insert": "cloud_portal.api.erpnext_sync.on_customer_insert",
},
"Subscription": {
"after_insert": "cloud_portal.api.erpnext_sync.on_subscription_insert",
},
}
# Each item in the list will be shown as an app in the apps page fixtures = [
# add_to_apps_screen = [ {
# { "dt": "Custom Field",
# "name": "cloud_portal", "filters": [["dt", "=", "Subscription"], ["fieldname", "=", "custom_remote_sub_id"]],
# "logo": "/assets/cloud_portal/logo.png", }
# "title": "Cloud Portal", ]
# "route": "/cloud_portal",
# "has_permission": "cloud_portal.api.permission.has_app_permission"
# }
# ]
# Includes in <head>
# ------------------
# include js, css files in header of desk.html
# app_include_css = "/assets/cloud_portal/css/cloud_portal.css"
# app_include_js = "/assets/cloud_portal/js/cloud_portal.js"
# include js, css files in header of web template
# web_include_css = "/assets/cloud_portal/css/cloud_portal.css"
# web_include_js = "/assets/cloud_portal/js/cloud_portal.js"
# include custom scss in every website theme (without file extension ".scss")
# website_theme_scss = "cloud_portal/public/scss/website"
# include js, css files in header of web form
# webform_include_js = {"doctype": "public/js/doctype.js"}
# webform_include_css = {"doctype": "public/css/doctype.css"}
# include js in page
# page_js = {"page" : "public/js/file.js"}
# include js in doctype views
# doctype_js = {"doctype" : "public/js/doctype.js"}
# doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"}
# Svg Icons
# ------------------
# include app icons in desk
# app_include_icons = "cloud_portal/public/icons.svg"
# Home Pages
# ----------
# application home page (will override Website Settings)
# home_page = "login"
# website user home page (by Role)
# role_home_page = {
# "Role": "home_page"
# }
# Generators
# ----------
# automatically create page for each record of this doctype
# website_generators = ["Web Page"]
# automatically load and sync documents of this doctype from downstream apps
# importable_doctypes = [doctype_1]
# Jinja
# ----------
# add methods and filters to jinja environment
# jinja = {
# "methods": "cloud_portal.utils.jinja_methods",
# "filters": "cloud_portal.utils.jinja_filters"
# }
# Installation
# ------------
# before_install = "cloud_portal.install.before_install"
# after_install = "cloud_portal.install.after_install"
# Uninstallation
# ------------
# before_uninstall = "cloud_portal.uninstall.before_uninstall"
# after_uninstall = "cloud_portal.uninstall.after_uninstall"
# Integration Setup
# ------------------
# To set up dependencies/integrations with other apps
# Name of the app being installed is passed as an argument
# before_app_install = "cloud_portal.utils.before_app_install"
# after_app_install = "cloud_portal.utils.after_app_install"
# Integration Cleanup
# -------------------
# To clean up dependencies/integrations with other apps
# Name of the app being uninstalled is passed as an argument
# before_app_uninstall = "cloud_portal.utils.before_app_uninstall"
# after_app_uninstall = "cloud_portal.utils.after_app_uninstall"
# Desk Notifications
# ------------------
# See frappe.core.notifications.get_notification_config
# notification_config = "cloud_portal.notifications.get_notification_config"
# Permissions
# -----------
# Permissions evaluated in scripted ways
# permission_query_conditions = {
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
# }
#
# has_permission = {
# "Event": "frappe.desk.doctype.event.event.has_permission",
# }
# Document Events
# ---------------
# Hook on document methods and events
# doc_events = {
# "*": {
# "on_update": "method",
# "on_cancel": "method",
# "on_trash": "method"
# }
# }
# Scheduled Tasks # Scheduled Tasks
# --------------- scheduler_events = {
"cron": {
# scheduler_events = { # Sync container statuses every minute
# "all": [ "* * * * *": [
# "cloud_portal.tasks.all" "cloud_portal.cloud_portal.doctype.cloud_container.cloud_container.sync_all_container_statuses"
# ], ],
# "daily": [ # Sync subscription statuses with remote ERPNext every hour
# "cloud_portal.tasks.daily" "0 * * * *": [
# ], "cloud_portal.api.erpnext_sync.sync_all_subscriptions"
# "hourly": [ ],
# "cloud_portal.tasks.hourly" }
# ], }
# "weekly": [
# "cloud_portal.tasks.weekly"
# ],
# "monthly": [
# "cloud_portal.tasks.monthly"
# ],
# }
# Testing
# -------
# before_tests = "cloud_portal.install.before_tests"
# Extend DocType Class
# ------------------------------
#
# Specify custom mixins to extend the standard doctype controller.
# extend_doctype_class = {
# "Task": "cloud_portal.custom.task.CustomTaskMixin"
# }
# Overriding Methods
# ------------------------------
#
# override_whitelisted_methods = {
# "frappe.desk.doctype.event.event.get_events": "cloud_portal.event.get_events"
# }
#
# each overriding function accepts a `data` argument;
# generated from the base implementation of the doctype dashboard,
# along with any modifications made in other Frappe apps
# override_doctype_dashboards = {
# "Task": "cloud_portal.task.get_dashboard_data"
# }
# exempt linked doctypes from being automatically cancelled
#
# auto_cancel_exempted_doctypes = ["Auto Repeat"]
# Ignore links to specified DocTypes when deleting documents
# -----------------------------------------------------------
# ignore_links_on_delete = ["Communication", "ToDo"]
# Request Events
# ----------------
# before_request = ["cloud_portal.utils.before_request"]
# after_request = ["cloud_portal.utils.after_request"]
# Job Events
# ----------
# before_job = ["cloud_portal.utils.before_job"]
# after_job = ["cloud_portal.utils.after_job"]
# User Data Protection
# --------------------
# user_data_fields = [
# {
# "doctype": "{doctype_1}",
# "filter_by": "{filter_by}",
# "redact_fields": ["{field_1}", "{field_2}"],
# "partial": 1,
# },
# {
# "doctype": "{doctype_2}",
# "filter_by": "{filter_by}",
# "partial": 1,
# },
# {
# "doctype": "{doctype_3}",
# "strict": False,
# },
# {
# "doctype": "{doctype_4}"
# }
# ]
# Authentication and authorization
# --------------------------------
# auth_hooks = [
# "cloud_portal.auth.validate"
# ]
# Automatically update python controller files with type annotations for this app.
# export_python_type_annotations = True
# default_log_clearing_doctypes = {
# "Logging DocType Name": 30 # days to retain logs
# }
# Translation
# ------------
# List of apps whose translatable strings should be excluded from this app's translations.
# ignore_translatable_strings_from = []

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
import{u as x,o,b as l,e as a,h as d,d as t,S as f,R as _,i as p,f as n,l as r,T as u,t as i,W as h}from"./index-9538feb4.js";import{_ as y}from"./LayoutHeader-d0c4e6dc.js";const g={class:"flex flex-col h-full"},b={class:"flex items-center gap-3"},k={class:"flex-1 overflow-auto p-5"},v={class:"rounded-lg border"},I={class:"w-full"},$={class:"px-4 py-3"},N={class:"font-medium text-ink-gray-9"},w={class:"px-4 py-3 text-sm text-ink-gray-7"},V={class:"px-4 py-3 text-sm font-medium text-ink-gray-9"},P={class:"px-4 py-3"},B={class:"px-4 py-3 text-right"},F={__name:"Invoices",setup(S){const m=x(),c=p([{id:"INV-2024-001",date:"January 15, 2024",amount:"$99.00",status:"Paid"},{id:"INV-2023-012",date:"December 15, 2023",amount:"$99.00",status:"Paid"},{id:"INV-2023-011",date:"November 15, 2023",amount:"$99.00",status:"Paid"},{id:"INV-2023-010",date:"October 15, 2023",amount:"$99.00",status:"Paid"},{id:"INV-2023-009",date:"September 15, 2023",amount:"$99.00",status:"Paid"},{id:"INV-2023-008",date:"August 15, 2023",amount:"$99.00",status:"Paid"}]);return(A,e)=>(o(),l("div",g,[a(y,null,{left:d(()=>[t("div",b,[a(n(r),{variant:"ghost",onClick:e[0]||(e[0]=s=>n(m).push({name:"Billing"}))},{default:d(()=>[a(n(u),{name:"arrow-left",class:"h-4 w-4"})]),_:1}),e[1]||(e[1]=t("h1",{class:"text-xl font-semibold text-ink-gray-9"},"Invoices",-1))])]),_:1}),t("div",k,[t("div",v,[t("table",I,[e[2]||(e[2]=t("thead",null,[t("tr",{class:"border-b bg-surface-gray-1"},[t("th",{class:"px-4 py-3 text-left text-sm font-medium text-ink-gray-5"}," Invoice "),t("th",{class:"px-4 py-3 text-left text-sm font-medium text-ink-gray-5"}," Date "),t("th",{class:"px-4 py-3 text-left text-sm font-medium text-ink-gray-5"}," Amount "),t("th",{class:"px-4 py-3 text-left text-sm font-medium text-ink-gray-5"}," Status "),t("th",{class:"px-4 py-3 text-right text-sm font-medium text-ink-gray-5"}," Actions ")])],-1)),t("tbody",null,[(o(!0),l(f,null,_(c.value,s=>(o(),l("tr",{key:s.id,class:"border-b last:border-0"},[t("td",$,[t("span",N,i(s.id),1)]),t("td",w,i(s.date),1),t("td",V,i(s.amount),1),t("td",P,[a(n(h),{variant:s.status==="Paid"?"success":"warning",label:s.status},null,8,["variant","label"])]),t("td",B,[a(n(r),{variant:"ghost",size:"sm"},{default:d(()=>[a(n(u),{name:"download",class:"h-4 w-4"})]),_:1})])]))),128))])])])])]))}};export{F as default};
//# sourceMappingURL=Invoices-f062ce25.js.map

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
import{o as a,b as r,d as e,A as s,t as i}from"./index-9538feb4.js";const l={class:"flex items-center justify-between border-b bg-surface-white px-5 py-2.5"},n={class:"flex items-center gap-2"},c={class:"text-xl font-semibold text-ink-gray-9"},d={class:"flex items-center gap-2"},f={__name:"LayoutHeader",props:{title:{type:String,default:""}},setup(o){return(t,_)=>(a(),r("header",l,[e("div",n,[s(t.$slots,"left",{},()=>[e("h1",c,i(o.title),1)])]),e("div",d,[s(t.$slots,"right")])]))}};export{f as _};
//# sourceMappingURL=LayoutHeader-d0c4e6dc.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"LayoutHeader-d0c4e6dc.js","sources":["../../../../frontend/src/components/Common/LayoutHeader.vue"],"sourcesContent":["<template>\n <header class=\"flex items-center justify-between border-b bg-surface-white px-5 py-2.5\">\n <div class=\"flex items-center gap-2\">\n <slot name=\"left\">\n <h1 class=\"text-xl font-semibold text-ink-gray-9\">\n {{ title }}\n </h1>\n </slot>\n </div>\n <div class=\"flex items-center gap-2\">\n <slot name=\"right\" />\n </div>\n </header>\n</template>\n\n<script setup>\ndefineProps({\n title: {\n type: String,\n default: '',\n },\n})\n</script>\n"],"names":["_openBlock","_createElementBlock","_hoisted_1","_createElementVNode","_hoisted_2","_renderSlot","_ctx","_hoisted_3","_toDisplayString","__props","_hoisted_4"],"mappings":"+WACEA,EAAA,EAAAC,EAWS,SAXTC,EAWS,CAVPC,EAMM,MANNC,EAMM,CALJC,EAIOC,mBAJP,IAIO,CAHLH,EAEK,KAFLI,EAEKC,EADAC,EAAK,KAAA,EAAA,CAAA,MAIdN,EAEM,MAFNO,EAEM,CADJL,EAAqBC,EAAA,OAAA,OAAA"}

View File

@ -0,0 +1,2 @@
import{u as _,a as y,c as b,r as V,o as i,b as u,d as s,w as h,e as r,f as d,t as k,g as P,h as m,i as c,j as S,_ as p,k as v,l as R}from"./index-9538feb4.js";const C={class:"min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"},N={class:"max-w-md w-full space-y-8"},q={class:"rounded-md shadow-sm space-y-4"},B={key:0,class:"rounded-md bg-red-50 p-4"},E={class:"text-sm text-red-700"},j={class:"text-center"},$={__name:"Login",setup(D){_();const f=y(),t=c(!1),o=c(""),a=S({email:"",password:""}),x=b({url:"login",makeParams(){return{usr:a.email,pwd:a.password}},onSuccess(){t.value=!1,o.value="";const l=f.query.redirect||"/sites";window.location.href="/portal"+l},onError(l){var e;t.value=!1,o.value=((e=l.messages)==null?void 0:e[0])||l.message||"Invalid email or password"}});function g(){if(!a.email||!a.password){o.value="Please enter email and password";return}o.value="",t.value=!0,x.submit()}return(l,e)=>{const w=V("router-link");return i(),u("div",C,[s("div",N,[e[4]||(e[4]=s("div",null,[s("h2",{class:"mt-6 text-center text-3xl font-extrabold text-gray-900"}," Cloud Portal "),s("p",{class:"mt-2 text-center text-sm text-gray-600"}," Sign in to your account ")],-1)),s("form",{class:"mt-8 space-y-6",onSubmit:h(g,["prevent"])},[s("div",q,[r(d(p),{label:"Email",type:"email",modelValue:a.email,"onUpdate:modelValue":e[0]||(e[0]=n=>a.email=n),placeholder:"you@example.com",disabled:t.value,required:""},null,8,["modelValue","disabled"]),r(d(p),{label:"Password",type:"password",modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=n=>a.password=n),placeholder:"Password",disabled:t.value,required:""},null,8,["modelValue","disabled"])]),o.value?(i(),u("div",B,[s("p",E,k(o.value),1)])):P("",!0),s("div",null,[r(d(R),{type:"submit",variant:"solid",class:"w-full",loading:t.value},{default:m(()=>[...e[2]||(e[2]=[v(" Sign in ",-1)])]),_:1},8,["loading"])]),s("div",j,[r(w,{to:{name:"Register"},class:"text-sm text-blue-600 hover:text-blue-500"},{default:m(()=>[...e[3]||(e[3]=[v(" Don't have an account? Sign up ",-1)])]),_:1})])],32)])])}}};export{$ as default};
//# sourceMappingURL=Login-2d17b53c.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"Login-2d17b53c.js","sources":["../../../../frontend/src/pages/Login.vue"],"sourcesContent":["<template>\n <div class=\"min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8\">\n <div class=\"max-w-md w-full space-y-8\">\n <div>\n <h2 class=\"mt-6 text-center text-3xl font-extrabold text-gray-900\">\n Cloud Portal\n </h2>\n <p class=\"mt-2 text-center text-sm text-gray-600\">\n Sign in to your account\n </p>\n </div>\n\n <form class=\"mt-8 space-y-6\" @submit.prevent=\"login\">\n <div class=\"rounded-md shadow-sm space-y-4\">\n <FormControl\n label=\"Email\"\n type=\"email\"\n v-model=\"form.email\"\n placeholder=\"you@example.com\"\n :disabled=\"loading\"\n required\n />\n\n <FormControl\n label=\"Password\"\n type=\"password\"\n v-model=\"form.password\"\n placeholder=\"Password\"\n :disabled=\"loading\"\n required\n />\n </div>\n\n <div v-if=\"error\" class=\"rounded-md bg-red-50 p-4\">\n <p class=\"text-sm text-red-700\">{{ error }}</p>\n </div>\n\n <div>\n <Button\n type=\"submit\"\n variant=\"solid\"\n class=\"w-full\"\n :loading=\"loading\"\n >\n Sign in\n </Button>\n </div>\n\n <div class=\"text-center\">\n <router-link\n :to=\"{ name: 'Register' }\"\n class=\"text-sm text-blue-600 hover:text-blue-500\"\n >\n Don't have an account? Sign up\n </router-link>\n </div>\n </form>\n </div>\n </div>\n</template>\n\n<script setup>\nimport { reactive, ref } from 'vue'\nimport { useRouter, useRoute } from 'vue-router'\nimport { Button, FormControl, createResource } from 'frappe-ui'\n\nconst router = useRouter()\nconst route = useRoute()\nconst loading = ref(false)\nconst error = ref('')\n\nconst form = reactive({\n email: '',\n password: '',\n})\n\nconst loginResource = createResource({\n url: 'login',\n makeParams() {\n return {\n usr: form.email,\n pwd: form.password,\n }\n },\n onSuccess() {\n loading.value = false\n error.value = ''\n const redirectPath = route.query.redirect || '/sites'\n window.location.href = '/portal' + redirectPath\n },\n onError(err) {\n loading.value = false\n error.value = err.messages?.[0] || err.message || 'Invalid email or password'\n }\n})\n\nfunction login() {\n if (!form.email || !form.password) {\n error.value = 'Please enter email and password'\n return\n }\n\n error.value = ''\n loading.value = true\n loginResource.submit()\n}\n</script>\n"],"names":["useRouter","route","useRoute","loading","ref","error","form","reactive","loginResource","createResource","redirectPath","err","_a","login","_openBlock","_createElementBlock","_hoisted_1","_createElementVNode","_hoisted_2","_hoisted_3","_createVNode","_unref","FormControl","_cache","$event","_hoisted_4","_hoisted_5","_toDisplayString","Button","_hoisted_6","_component_router_link"],"mappings":"qdAkEeA,EAAU,EACzB,MAAMC,EAAQC,EAAS,EACjBC,EAAUC,EAAI,EAAK,EACnBC,EAAQD,EAAI,EAAE,EAEdE,EAAOC,EAAS,CACpB,MAAO,GACP,SAAU,EACZ,CAAC,EAEKC,EAAgBC,EAAe,CACnC,IAAK,QACL,YAAa,CACX,MAAO,CACL,IAAKH,EAAK,MACV,IAAKA,EAAK,QACZ,CACD,EACD,WAAY,CACVH,EAAQ,MAAQ,GAChBE,EAAM,MAAQ,GACd,MAAMK,EAAeT,EAAM,MAAM,UAAY,SAC7C,OAAO,SAAS,KAAO,UAAYS,CACpC,EACD,QAAQC,EAAK,OACXR,EAAQ,MAAQ,GAChBE,EAAM,QAAQO,EAAAD,EAAI,WAAJ,YAAAC,EAAe,KAAMD,EAAI,SAAW,2BACpD,CACF,CAAC,EAED,SAASE,GAAQ,CACf,GAAI,CAACP,EAAK,OAAS,CAACA,EAAK,SAAU,CACjCD,EAAM,MAAQ,kCACd,MACF,CAEAA,EAAM,MAAQ,GACdF,EAAQ,MAAQ,GAChBK,EAAc,OAAO,CACvB,wCAxGE,OAAAM,EAAA,EAAAC,EAyDM,MAzDNC,EAyDM,CAxDJC,EAuDM,MAvDNC,EAuDM,aAtDJD,EAOM,MAAA,KAAA,CANJA,EAEK,KAFD,CAAA,MAAM,wDAAwD,EAAC,gBAEnE,EACAA,EAEI,IAFD,CAAA,MAAM,wCAAwC,EAAC,2BAElD,QAGFA,EA4CO,OAAA,CA5CD,MAAM,iBAAkB,WAAgBJ,EAAK,CAAA,SAAA,CAAA,IACjDI,EAkBM,MAlBNE,EAkBM,CAjBJC,EAOEC,EAAAC,CAAA,EAAA,CANA,MAAM,QACN,KAAK,QACI,WAAAhB,EAAK,MAAL,sBAAAiB,EAAA,CAAA,IAAAA,EAAA,CAAA,EAAAC,GAAAlB,EAAK,MAAKkB,GACnB,YAAY,kBACX,SAAUrB,EAAO,MAClB,SAAA,sCAGFiB,EAOEC,EAAAC,CAAA,EAAA,CANA,MAAM,WACN,KAAK,WACI,WAAAhB,EAAK,SAAL,sBAAAiB,EAAA,CAAA,IAAAA,EAAA,CAAA,EAAAC,GAAAlB,EAAK,SAAQkB,GACtB,YAAY,WACX,SAAUrB,EAAO,MAClB,SAAA,wCAIOE,EAAK,OAAhBS,IAAAC,EAEM,MAFNU,EAEM,CADJR,EAA+C,IAA/CS,EAA+CC,EAAZtB,EAAK,KAAA,EAAA,CAAA,cAG1CY,EASM,MAAA,KAAA,CARJG,EAOSC,EAAAO,CAAA,EAAA,CANP,KAAK,SACL,QAAQ,QACR,MAAM,SACL,QAASzB,EAAO,kBAClB,IAED,CAAA,GAAAoB,EAAA,CAAA,IAAAA,EAAA,CAAA,EAAA,GAFC,YAED,EAAA,4BAGFN,EAOM,MAPNY,EAOM,CANJT,EAKcU,EAAA,CAJX,GAAI,CAAoB,KAAA,UAAA,EACzB,MAAM,wDACP,IAED,CAAA,GAAAP,EAAA,CAAA,IAAAA,EAAA,CAAA,EAAA,GAFC,mCAED,EAAA"}

View File

@ -0,0 +1,2 @@
import{u as n,o as r,b as a,d as e,e as l,h as i,f as s,k as d,l as u}from"./index-9538feb4.js";const x={class:"flex h-full flex-col items-center justify-center"},f={class:"text-center"},g={__name:"NotFound",setup(m){const o=n();return(p,t)=>(r(),a("div",x,[e("div",f,[t[2]||(t[2]=e("h1",{class:"text-6xl font-bold text-ink-gray-3 mb-4"},"404",-1)),t[3]||(t[3]=e("h2",{class:"text-2xl font-semibold text-ink-gray-9 mb-2"},"Page Not Found",-1)),t[4]||(t[4]=e("p",{class:"text-ink-gray-5 mb-6"}," The page you're looking for doesn't exist or has been moved. ",-1)),l(s(u),{variant:"solid",onClick:t[0]||(t[0]=k=>s(o).push({name:"Sites"}))},{default:i(()=>[...t[1]||(t[1]=[d(" Go to Sites ",-1)])]),_:1})])]))}};export{g as default};
//# sourceMappingURL=NotFound-bc2f109f.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"NotFound-bc2f109f.js","sources":["../../../../frontend/src/pages/NotFound.vue"],"sourcesContent":["<template>\n <div class=\"flex h-full flex-col items-center justify-center\">\n <div class=\"text-center\">\n <h1 class=\"text-6xl font-bold text-ink-gray-3 mb-4\">404</h1>\n <h2 class=\"text-2xl font-semibold text-ink-gray-9 mb-2\">Page Not Found</h2>\n <p class=\"text-ink-gray-5 mb-6\">\n The page you're looking for doesn't exist or has been moved.\n </p>\n <Button variant=\"solid\" @click=\"router.push({ name: 'Sites' })\">\n Go to Sites\n </Button>\n </div>\n </div>\n</template>\n\n<script setup>\nimport { useRouter } from 'vue-router'\nimport { Button } from 'frappe-ui'\n\nconst router = useRouter()\n</script>\n"],"names":["router","useRouter","_openBlock","_createElementBlock","_hoisted_1","_createElementVNode","_hoisted_2","_cache","_createVNode","_unref","Button","$event"],"mappings":"yNAmBA,MAAMA,EAASC,EAAU,gBAlBvBC,EAAA,EAAAC,EAWM,MAXNC,EAWM,CAVJC,EASM,MATNC,EASM,CARJC,EAAA,CAAA,IAAAA,EAAA,CAAA,EAAAF,EAA4D,KAAxD,CAAA,MAAM,yCAAyC,EAAC,MAAG,EAAA,GACvDE,EAAA,CAAA,IAAAA,EAAA,CAAA,EAAAF,EAA2E,KAAvE,CAAA,MAAM,6CAA6C,EAAC,iBAAc,EAAA,GACtEE,EAAA,CAAA,IAAAA,EAAA,CAAA,EAAAF,EAEI,IAFD,CAAA,MAAM,sBAAsB,EAAC,iEAEhC,EAAA,GACAG,EAESC,EAAAC,CAAA,EAAA,CAFD,QAAQ,QAAS,QAAKH,EAAA,CAAA,IAAAA,EAAA,CAAA,EAAAI,GAAEF,EAAMT,CAAA,EAAC,KAAI,CAAA,KAAA,OAAA,CAAA,eAAqB,IAEhE,CAAA,GAAAO,EAAA,CAAA,IAAAA,EAAA,CAAA,EAAA,GAFgE,gBAEhE,EAAA"}

View File

@ -0,0 +1,2 @@
import{c as h,r as R,o as u,b as d,d as l,w as V,k as b,m as A,v as S,n as U,e as i,h as y,f as c,t as x,g as v,i as p,j as q,l as k,_}from"./index-9538feb4.js";const L={class:"min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"},T={class:"max-w-md w-full space-y-8"},B={class:"rounded-md shadow-sm space-y-4"},D={class:"flex gap-2"},K=["disabled","onKeydown"],O={key:0,class:"mt-2 rounded-md bg-blue-50 p-3"},j={class:"text-sm font-medium text-blue-800"},z={class:"text-xs text-blue-600 mt-0.5"},M={class:"flex gap-3 mt-1"},$={key:0,class:"text-xs text-green-600"},F={key:1,class:"text-xs text-blue-600"},I={key:1,class:"mt-1 text-sm text-red-600"},J={key:0,class:"rounded-md bg-red-50 p-4"},G={class:"text-sm text-red-700"},H={key:1,class:"rounded-md bg-green-50 p-4"},Q={class:"text-sm text-green-700"},W={class:"text-center"},Z={__name:"Register",setup(X){const t=p(!1),o=p(""),g=p(""),f=p(!1),m=p(""),n=p(null),e=q({voen:"",fullName:"",email:"",password:"",confirmPassword:""}),N=h({url:"cloud_portal.api.voen.lookup",onSuccess(a){f.value=!1,a.success?(n.value=a,m.value="",e.fullName||(e.fullName=a.name)):(n.value=null,m.value=a.message||"Organization not found")},onError(a){var s;f.value=!1,n.value=null,m.value=((s=a.messages)==null?void 0:s[0])||a.message||"Lookup failed"}});function w(){e.voen&&(f.value=!0,m.value="",n.value=null,N.fetch({voen:e.voen}))}const P=h({url:"cloud_portal.api.auth.register_user",onSuccess(a){t.value=!1,a.success?(g.value="Account created! Redirecting...",o.value="",setTimeout(()=>{window.location.href="/portal/sites"},500)):o.value=a.message||"Registration failed"},onError(a){var s;t.value=!1,o.value=((s=a.messages)==null?void 0:s[0])||a.message||"Registration failed"}});function C(){if(!e.voen){o.value="Please enter your VOEN (ИНН)";return}if(!e.fullName||!e.email||!e.password){o.value="Please fill in all fields";return}if(e.password.length<6){o.value="Password must be at least 6 characters";return}if(e.password!==e.confirmPassword){o.value="Passwords do not match";return}o.value="",g.value="",t.value=!0,P.fetch({full_name:e.fullName,email:e.email,password:e.password,voen:e.voen})}return(a,s)=>{const E=R("router-link");return u(),d("div",L,[l("div",T,[s[9]||(s[9]=l("div",null,[l("h2",{class:"mt-6 text-center text-3xl font-extrabold text-gray-900"}," Cloud Portal "),l("p",{class:"mt-2 text-center text-sm text-gray-600"}," Create your account ")],-1)),l("form",{class:"mt-8 space-y-6",onSubmit:V(C,["prevent"])},[l("div",B,[l("div",null,[s[6]||(s[6]=l("label",{class:"block text-sm font-medium text-gray-700 mb-1"},[b(" VOEN (ИНН) "),l("span",{class:"text-red-500"},"*")],-1)),l("div",D,[A(l("input",{type:"text","onUpdate:modelValue":s[0]||(s[0]=r=>e.voen=r),placeholder:"0000000000",disabled:t.value||f.value,class:"flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100",onKeydown:U(V(w,["prevent"]),["enter"])},null,40,K),[[S,e.voen]]),i(c(k),{type:"button",variant:"subtle",loading:f.value,disabled:!e.voen||t.value,onClick:w},{default:y(()=>[...s[5]||(s[5]=[b(" Lookup ",-1)])]),_:1},8,["loading","disabled"])]),n.value?(u(),d("div",O,[l("p",j,x(n.value.name),1),l("p",z,x(n.value.organization_type),1),l("div",M,[n.value.active?(u(),d("span",$,"Active")):v("",!0),n.value.vat_payer?(u(),d("span",F,"VAT Payer")):v("",!0)])])):v("",!0),m.value?(u(),d("p",I,x(m.value),1)):v("",!0)]),i(c(_),{label:"Full Name",type:"text",modelValue:e.fullName,"onUpdate:modelValue":s[1]||(s[1]=r=>e.fullName=r),placeholder:"John Doe",disabled:t.value,required:""},null,8,["modelValue","disabled"]),i(c(_),{label:"Email",type:"email",modelValue:e.email,"onUpdate:modelValue":s[2]||(s[2]=r=>e.email=r),placeholder:"you@example.com",disabled:t.value,required:""},null,8,["modelValue","disabled"]),i(c(_),{label:"Password",type:"password",modelValue:e.password,"onUpdate:modelValue":s[3]||(s[3]=r=>e.password=r),placeholder:"Password (min 6 characters)",disabled:t.value,required:""},null,8,["modelValue","disabled"]),i(c(_),{label:"Confirm Password",type:"password",modelValue:e.confirmPassword,"onUpdate:modelValue":s[4]||(s[4]=r=>e.confirmPassword=r),placeholder:"Confirm password",disabled:t.value,required:""},null,8,["modelValue","disabled"])]),o.value?(u(),d("div",J,[l("p",G,x(o.value),1)])):v("",!0),g.value?(u(),d("div",H,[l("p",Q,x(g.value),1)])):v("",!0),l("div",null,[i(c(k),{type:"submit",variant:"solid",class:"w-full",loading:t.value},{default:y(()=>[...s[7]||(s[7]=[b(" Create Account ",-1)])]),_:1},8,["loading"])]),l("div",W,[i(E,{to:{name:"Login"},class:"text-sm text-blue-600 hover:text-blue-500"},{default:y(()=>[...s[8]||(s[8]=[b(" Already have an account? Sign in ",-1)])]),_:1})])],32)])])}}};export{Z as default};
//# sourceMappingURL=Register-1898e04e.js.map

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
import{o as i,b as d,e as l,d as t,S as g,R as w,h as m,f as o,g as p,i as V,j as y,U as P,t as b,k as c,l as f,_ as r,m as h,Y as S}from"./index-9538feb4.js";import{_ as U}from"./LayoutHeader-d0c4e6dc.js";const C={class:"flex flex-col h-full"},N={class:"flex-1 overflow-auto p-5"},j={class:"max-w-3xl"},A={class:"mb-6 flex gap-2 border-b"},B=["onClick"],D={key:0,class:"space-y-6"},E={class:"rounded-lg border p-5"},F={class:"space-y-4"},$={class:"flex items-center gap-4"},R={class:"mt-6 flex justify-end"},T={key:1,class:"space-y-6"},G={class:"rounded-lg border p-5"},J={class:"space-y-4"},z={class:"mt-6 flex justify-end"},L={class:"rounded-lg border p-5"},M={key:2,class:"space-y-6"},O={class:"rounded-lg border p-5"},Y={class:"space-y-4"},q={class:"font-medium text-ink-gray-9"},H={class:"text-sm text-ink-gray-5"},I=["onUpdate:modelValue"],K={class:"mt-6 flex justify-end"},ee={__name:"Settings",setup(Q){const u=V("profile"),k=[{name:"profile",label:"Profile"},{name:"security",label:"Security"},{name:"notifications",label:"Notifications"}],a=y({name:"John Doe",email:"john@example.com",phone:"+91 98765 43210"}),n=y({currentPassword:"",newPassword:"",confirmPassword:""}),_=[{key:"siteStatus",label:"Site Status Alerts",description:"Get notified when your site status changes"},{key:"billing",label:"Billing Updates",description:"Receive invoices and payment reminders"},{key:"security",label:"Security Alerts",description:"Get notified about security-related events"},{key:"newsletter",label:"Product Updates",description:"Receive news about new features and updates"}],v=y({siteStatus:!0,billing:!0,security:!0,newsletter:!1});return(W,e)=>(i(),d("div",C,[l(U,{title:"Settings"}),t("div",N,[t("div",j,[t("div",A,[(i(),d(g,null,w(k,s=>t("button",{key:s.name,class:P(["px-4 py-2 text-sm font-medium transition-colors",u.value===s.name?"border-b-2 border-ink-gray-9 text-ink-gray-9":"text-ink-gray-5 hover:text-ink-gray-7"]),onClick:x=>u.value=s.name},b(s.label),11,B)),64))]),u.value==="profile"?(i(),d("div",D,[t("div",E,[e[9]||(e[9]=t("h2",{class:"text-lg font-semibold text-ink-gray-9 mb-4"},"Profile",-1)),t("div",F,[t("div",$,[e[7]||(e[7]=t("div",{class:"flex h-16 w-16 items-center justify-center rounded-full bg-surface-gray-2 text-2xl font-semibold text-ink-gray-5"}," JD ",-1)),l(o(f),{variant:"subtle"},{default:m(()=>[...e[6]||(e[6]=[c("Change Avatar",-1)])]),_:1})]),l(o(r),{label:"Full Name",modelValue:a.name,"onUpdate:modelValue":e[0]||(e[0]=s=>a.name=s)},null,8,["modelValue"]),l(o(r),{label:"Email",modelValue:a.email,"onUpdate:modelValue":e[1]||(e[1]=s=>a.email=s),type:"email"},null,8,["modelValue"]),l(o(r),{label:"Phone",modelValue:a.phone,"onUpdate:modelValue":e[2]||(e[2]=s=>a.phone=s),type:"tel"},null,8,["modelValue"])]),t("div",R,[l(o(f),{variant:"solid"},{default:m(()=>[...e[8]||(e[8]=[c("Save Changes",-1)])]),_:1})])])])):p("",!0),u.value==="security"?(i(),d("div",T,[t("div",G,[e[11]||(e[11]=t("h2",{class:"text-lg font-semibold text-ink-gray-9 mb-4"}," Change Password ",-1)),t("div",J,[l(o(r),{label:"Current Password",type:"password",modelValue:n.currentPassword,"onUpdate:modelValue":e[3]||(e[3]=s=>n.currentPassword=s)},null,8,["modelValue"]),l(o(r),{label:"New Password",type:"password",modelValue:n.newPassword,"onUpdate:modelValue":e[4]||(e[4]=s=>n.newPassword=s)},null,8,["modelValue"]),l(o(r),{label:"Confirm New Password",type:"password",modelValue:n.confirmPassword,"onUpdate:modelValue":e[5]||(e[5]=s=>n.confirmPassword=s)},null,8,["modelValue"])]),t("div",z,[l(o(f),{variant:"solid"},{default:m(()=>[...e[10]||(e[10]=[c("Update Password",-1)])]),_:1})])]),t("div",L,[e[13]||(e[13]=t("h2",{class:"text-lg font-semibold text-ink-gray-9 mb-4"}," Two-Factor Authentication ",-1)),e[14]||(e[14]=t("p",{class:"text-sm text-ink-gray-5 mb-4"}," Add an extra layer of security to your account by enabling two-factor authentication. ",-1)),l(o(f),{variant:"subtle"},{default:m(()=>[...e[12]||(e[12]=[c("Enable 2FA",-1)])]),_:1})])])):p("",!0),u.value==="notifications"?(i(),d("div",M,[t("div",O,[e[16]||(e[16]=t("h2",{class:"text-lg font-semibold text-ink-gray-9 mb-4"}," Email Notifications ",-1)),t("div",Y,[(i(),d(g,null,w(_,s=>t("label",{key:s.key,class:"flex items-center justify-between"},[t("div",null,[t("p",q,b(s.label),1),t("p",H,b(s.description),1)]),h(t("input",{type:"checkbox","onUpdate:modelValue":x=>v[s.key]=x,class:"h-4 w-4 rounded border-gray-300"},null,8,I),[[S,v[s.key]]])])),64))]),t("div",K,[l(o(f),{variant:"solid"},{default:m(()=>[...e[15]||(e[15]=[c("Save Preferences",-1)])]),_:1})])])])):p("",!0)])])]))}};export{ee as default};
//# sourceMappingURL=Settings-93a00d9d.js.map

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
import{u as $,c as A,y as n,M as B,o as r,b as d,e as o,h as g,d as t,f as l,t as a,g as x,U as m,i as f,l as D,T as _,W as E,Z as p}from"./index-9538feb4.js";import{_ as R}from"./LayoutHeader-d0c4e6dc.js";import{g as T}from"./statusStyles-743ec7f5.js";const j={class:"flex flex-col h-full"},F={class:"flex items-center gap-3"},L={class:"text-xl font-semibold text-ink-gray-9"},M={class:"flex items-center gap-2"},U={class:"flex-1 overflow-auto p-5"},q={key:0,class:"flex items-center justify-center h-64"},z={key:1,class:"grid gap-6 lg:grid-cols-3"},O={class:"lg:col-span-2 space-y-6"},P={class:"rounded-lg border p-5"},W={class:"grid gap-4 sm:grid-cols-2"},Z={class:"text-sm font-medium text-ink-gray-9 font-mono"},G={class:"text-sm font-medium text-ink-gray-9"},H={class:"text-sm font-medium text-ink-gray-9 font-mono"},J={class:"text-sm font-medium text-ink-gray-9"},K={class:"text-sm font-medium text-ink-gray-9"},Q={key:0,class:"rounded-lg border border-red-200 bg-red-50 p-5"},X={class:"text-sm text-red-600"},Y={class:"rounded-lg border p-5"},tt={class:"flex items-center gap-4"},et={class:"text-sm text-ink-gray-5"},st={class:"space-y-6"},at={key:0,class:"rounded-lg border border-blue-200 bg-blue-50 p-5"},nt={class:"flex items-center gap-3"},dt={__name:"SiteDetail",props:{siteId:{type:String,required:!0}},setup(v){const y=v,b=$(),s=f({}),c=f(!0),h=A({url:"frappe.client.get",onSuccess(u){s.value=u,c.value=!1},onError(u){console.error("Failed to fetch container:",u),c.value=!1}});function k(){h.fetch({doctype:"Cloud Container",name:y.siteId})}const i=n(()=>T(s.value.status)),C=n(()=>i.value.badgeVariant),w=n(()=>i.value.icon),S=n(()=>i.value.bgClass),I=n(()=>i.value.iconClass),N=n(()=>i.value.textClass),V=n(()=>s.value.creation?new Date(s.value.creation).toLocaleString():"N/A");return B(()=>{k()}),(u,e)=>(r(),d("div",j,[o(R,null,{left:g(()=>[t("div",F,[o(l(D),{variant:"ghost",onClick:e[0]||(e[0]=ot=>l(b).back())},{default:g(()=>[o(l(_),{name:"arrow-left",class:"h-4 w-4"})]),_:1}),t("div",null,[t("h1",L,a(s.value.container_name||v.siteId),1),t("div",M,[o(l(E),{variant:C.value,label:s.value.status||"Loading"},null,8,["variant","label"])])])])]),_:1}),t("div",U,[c.value?(r(),d("div",q,[o(l(p),{class:"h-8 w-8"})])):(r(),d("div",z,[t("div",O,[t("div",P,[e[6]||(e[6]=t("h2",{class:"text-lg font-semibold text-ink-gray-9 mb-4"},"Overview",-1)),t("div",W,[t("div",null,[e[1]||(e[1]=t("p",{class:"text-sm text-ink-gray-5"},"Container Name",-1)),t("p",Z,a(s.value.container_name),1)]),t("div",null,[e[2]||(e[2]=t("p",{class:"text-sm text-ink-gray-5"},"Image",-1)),t("p",G,a(s.value.image),1)]),t("div",null,[e[3]||(e[3]=t("p",{class:"text-sm text-ink-gray-5"},"IP Address",-1)),t("p",H,a(s.value.ip_address||"Not assigned"),1)]),t("div",null,[e[4]||(e[4]=t("p",{class:"text-sm text-ink-gray-5"},"Architecture",-1)),t("p",J,a(s.value.architecture||"N/A"),1)]),t("div",null,[e[5]||(e[5]=t("p",{class:"text-sm text-ink-gray-5"},"Created",-1)),t("p",K,a(V.value),1)])])]),s.value.error_message?(r(),d("div",Q,[e[7]||(e[7]=t("h2",{class:"text-lg font-semibold text-red-700 mb-2"},"Error",-1)),t("p",X,a(s.value.error_message),1)])):x("",!0),t("div",Y,[e[8]||(e[8]=t("h2",{class:"text-lg font-semibold text-ink-gray-9 mb-4"},"Container Status",-1)),t("div",tt,[t("div",{class:m(["flex h-16 w-16 items-center justify-center rounded-full",S.value])},[o(l(_),{name:w.value,class:m(["h-8 w-8",I.value])},null,8,["name","class"])],2),t("div",null,[t("p",{class:m(["text-2xl font-semibold",N.value])},a(s.value.status),3),t("p",et,"Incus Status: "+a(s.value.incus_status||"Unknown"),1)])])])]),t("div",st,[s.value.status==="Creating"?(r(),d("div",at,[t("div",nt,[o(l(p),{class:"h-5 w-5 text-blue-600"}),e[9]||(e[9]=t("div",null,[t("p",{class:"font-medium text-blue-700"},"Creating Container"),t("p",{class:"text-sm text-blue-600"},"This may take a moment...")],-1))])])):x("",!0)])]))])]))}};export{dt as default};
//# sourceMappingURL=SiteDetail-29b29f5b.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
const t={Running:{badgeVariant:"success",icon:"play-circle",bgClass:"bg-green-100",iconClass:"text-green-600",textClass:"text-green-600"},Stopped:{badgeVariant:"warning",icon:"pause-circle",bgClass:"bg-orange-100",iconClass:"text-orange-600",textClass:"text-orange-600"},Creating:{badgeVariant:"info",icon:"loader",bgClass:"bg-blue-100",iconClass:"text-blue-600",textClass:"text-blue-600"},Pending:{badgeVariant:"info",icon:"loader",bgClass:"bg-blue-100",iconClass:"text-blue-600",textClass:"text-blue-600"},Error:{badgeVariant:"error",icon:"alert-circle",bgClass:"bg-red-100",iconClass:"text-red-600",textClass:"text-red-600"}},a={badgeVariant:"subtle",icon:"server",bgClass:"bg-gray-100",iconClass:"text-gray-600",textClass:"text-gray-600"};function s(e){return t[e]||a}export{s as g};
//# sourceMappingURL=statusStyles-743ec7f5.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"statusStyles-743ec7f5.js","sources":["../../../../frontend/src/utils/statusStyles.js"],"sourcesContent":["/**\n * Centralized status styles for container statuses.\n * Used by SiteCard.vue and SiteDetail.vue components.\n */\n\nexport const STATUS_STYLES = {\n Running: {\n badgeVariant: 'success',\n icon: 'play-circle',\n bgClass: 'bg-green-100',\n iconClass: 'text-green-600',\n textClass: 'text-green-600',\n },\n Stopped: {\n badgeVariant: 'warning',\n icon: 'pause-circle',\n bgClass: 'bg-orange-100',\n iconClass: 'text-orange-600',\n textClass: 'text-orange-600',\n },\n Creating: {\n badgeVariant: 'info',\n icon: 'loader',\n bgClass: 'bg-blue-100',\n iconClass: 'text-blue-600',\n textClass: 'text-blue-600',\n },\n Pending: {\n badgeVariant: 'info',\n icon: 'loader',\n bgClass: 'bg-blue-100',\n iconClass: 'text-blue-600',\n textClass: 'text-blue-600',\n },\n Error: {\n badgeVariant: 'error',\n icon: 'alert-circle',\n bgClass: 'bg-red-100',\n iconClass: 'text-red-600',\n textClass: 'text-red-600',\n },\n}\n\nconst DEFAULT_STYLE = {\n badgeVariant: 'subtle',\n icon: 'server',\n bgClass: 'bg-gray-100',\n iconClass: 'text-gray-600',\n textClass: 'text-gray-600',\n}\n\n/**\n * Get styles for a given container status.\n * @param {string} status - The container status\n * @returns {Object} Style object with badgeVariant, icon, bgClass, iconClass, textClass\n */\nexport function getStatusStyles(status) {\n return STATUS_STYLES[status] || DEFAULT_STYLE\n}\n"],"names":["STATUS_STYLES","DEFAULT_STYLE","getStatusStyles","status"],"mappings":"AAKO,MAAMA,EAAgB,CAC3B,QAAS,CACP,aAAc,UACd,KAAM,cACN,QAAS,eACT,UAAW,iBACX,UAAW,gBACZ,EACD,QAAS,CACP,aAAc,UACd,KAAM,eACN,QAAS,gBACT,UAAW,kBACX,UAAW,iBACZ,EACD,SAAU,CACR,aAAc,OACd,KAAM,SACN,QAAS,cACT,UAAW,gBACX,UAAW,eACZ,EACD,QAAS,CACP,aAAc,OACd,KAAM,SACN,QAAS,cACT,UAAW,gBACX,UAAW,eACZ,EACD,MAAO,CACL,aAAc,QACd,KAAM,eACN,QAAS,aACT,UAAW,eACX,UAAW,cACZ,CACH,EAEMC,EAAgB,CACpB,aAAc,SACd,KAAM,SACN,QAAS,cACT,UAAW,gBACX,UAAW,eACb,EAOO,SAASC,EAAgBC,EAAQ,CACtC,OAAOH,EAAcG,CAAM,GAAKF,CAClC"}

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html class="h-full" lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover maximum-scale=1.0, user-scalable=no"
/>
<title>Cloud Portal</title>
<script type="module" crossorigin src="/assets/cloud_portal/frontend/assets/index-9538feb4.js"></script>
<link rel="stylesheet" href="/assets/cloud_portal/frontend/assets/index-cf282bc8.css">
</head>
<body class="h-full">
<div id="app" class="h-full"></div>
<script>
{% for key in boot %}
window["{{ key }}"] = {{ boot[key] | tojson }};
{% endfor %}
</script>
</body>
</html>

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html class="h-full" lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover maximum-scale=1.0, user-scalable=no"
/>
<title>Cloud Portal</title>
<script type="module" crossorigin src="/assets/cloud_portal/frontend/assets/index-9538feb4.js"></script>
<link rel="stylesheet" href="/assets/cloud_portal/frontend/assets/index-cf282bc8.css">
</head>
<body class="h-full">
<div id="app" class="h-full"></div>
<script>
{% for key in boot %}
window["{{ key }}"] = {{ boot[key] | tojson }};
{% endfor %}
</script>
</body>
</html>

View File

@ -0,0 +1,22 @@
import frappe
no_cache = 1
def get_context(context):
context.boot = get_boot()
frappe.db.commit()
@frappe.whitelist(allow_guest=True)
def get_context_for_dev():
if not frappe.conf.developer_mode:
frappe.throw("This method is only available in developer mode")
return get_boot()
def get_boot():
return {
"csrf_token": frappe.sessions.get_csrf_token(),
"user": frappe.session.user,
}

10
frontend/auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}

28
frontend/components.d.ts vendored Normal file
View File

@ -0,0 +1,28 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AppHeader: typeof import('./src/components/Layouts/AppHeader.vue')['default']
AppSidebar: typeof import('./src/components/Layouts/AppSidebar.vue')['default']
BillingIcon: typeof import('./src/components/Icons/BillingIcon.vue')['default']
CreateSiteModal: typeof import('./src/components/Sites/CreateSiteModal.vue')['default']
DesktopLayout: typeof import('./src/components/Layouts/DesktopLayout.vue')['default']
EmptyState: typeof import('./src/components/Common/EmptyState.vue')['default']
InvoiceList: typeof import('./src/components/Billing/InvoiceList.vue')['default']
LayoutHeader: typeof import('./src/components/Common/LayoutHeader.vue')['default']
PortalLogo: typeof import('./src/components/Icons/PortalLogo.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SettingsIcon: typeof import('./src/components/Icons/SettingsIcon.vue')['default']
SidebarLink: typeof import('./src/components/Common/SidebarLink.vue')['default']
SiteCard: typeof import('./src/components/Sites/SiteCard.vue')['default']
SitesIcon: typeof import('./src/components/Icons/SitesIcon.vue')['default']
SubscriptionCard: typeof import('./src/components/Billing/SubscriptionCard.vue')['default']
}
}

15
frontend/index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html class="h-full" lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover maximum-scale=1.0, user-scalable=no"
/>
<title>Cloud Portal</title>
</head>
<body class="h-full">
<div id="app" class="h-full"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

24
frontend/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "cloud-portal-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build --base=/assets/cloud_portal/frontend/ && yarn copy-html-entry",
"copy-html-entry": "cp ../cloud_portal/public/frontend/index.html ../cloud_portal/www/portal.html",
"serve": "vite preview"
},
"dependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"frappe-ui": "0.1.265",
"vite": "^4.4.9",
"vue": "^3.5.13",
"vue-router": "^4.2.2"
},
"devDependencies": {
"autoprefixer": "^10.4.14",
"postcss": "^8.4.5",
"tailwindcss": "^3.4.15"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

43
frontend/src/App.vue Normal file
View File

@ -0,0 +1,43 @@
<template>
<FrappeUIProvider>
<!-- Loading state while checking auth -->
<div v-if="loading" class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-gray-300 border-t-blue-600"></div>
<p class="mt-2 text-gray-500">Loading...</p>
</div>
</div>
<!-- Auth pages without sidebar -->
<template v-else-if="isAuthPage">
<router-view />
</template>
<!-- Main app with sidebar -->
<template v-else>
<DesktopLayout>
<router-view />
</DesktopLayout>
</template>
</FrappeUIProvider>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { FrappeUIProvider } from 'frappe-ui'
import DesktopLayout from '@/components/Layouts/DesktopLayout.vue'
import { session } from '@/session'
const route = useRoute()
const loading = ref(true)
const isAuthPage = computed(() => {
return ['Login', 'Register'].includes(route.name)
})
onMounted(async () => {
await session.init()
loading.value = false
})
</script>

View File

@ -0,0 +1,41 @@
<template>
<div class="space-y-2">
<div
v-for="invoice in invoices"
:key="invoice.id"
class="flex items-center justify-between rounded-lg bg-surface-gray-1 px-4 py-3"
>
<div class="flex items-center gap-4">
<div>
<p class="font-medium text-ink-gray-9">{{ invoice.id }}</p>
<p class="text-sm text-ink-gray-5">{{ invoice.date }}</p>
</div>
</div>
<div class="flex items-center gap-4">
<span class="font-medium text-ink-gray-9">{{ invoice.amount }}</span>
<Badge
:variant="invoice.status === 'Paid' ? 'success' : 'warning'"
:label="invoice.status"
/>
<Button variant="ghost" size="sm">
<FeatherIcon name="download" class="h-4 w-4" />
</Button>
</div>
</div>
<div v-if="!invoices.length" class="py-8 text-center text-ink-gray-5">
No invoices yet
</div>
</div>
</template>
<script setup>
import { Badge, Button, FeatherIcon } from 'frappe-ui'
defineProps({
invoices: {
type: Array,
default: () => [],
},
})
</script>

View File

@ -0,0 +1,61 @@
<template>
<div class="rounded-lg border p-5">
<div class="flex items-start justify-between mb-6">
<div>
<p class="text-sm text-ink-gray-5">Current Plan</p>
<h2 class="text-2xl font-semibold text-ink-gray-9">
{{ subscription.plan }}
</h2>
</div>
<Button variant="subtle">Change Plan</Button>
</div>
<div class="grid gap-6 sm:grid-cols-3">
<div>
<p class="text-sm text-ink-gray-5">Monthly Cost</p>
<p class="text-xl font-semibold text-ink-gray-9">
{{ subscription.price }}
<span class="text-sm font-normal text-ink-gray-5">
/ {{ subscription.period }}
</span>
</p>
</div>
<div>
<p class="text-sm text-ink-gray-5">Next Billing Date</p>
<p class="text-xl font-semibold text-ink-gray-9">
{{ subscription.nextBillingDate }}
</p>
</div>
<div>
<p class="text-sm text-ink-gray-5">Sites</p>
<p class="text-xl font-semibold text-ink-gray-9">
{{ subscription.sites }}
<span class="text-sm font-normal text-ink-gray-5">
/ {{ subscription.maxSites }}
</span>
</p>
</div>
</div>
<div class="mt-6 pt-6 border-t">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-green-500"></div>
<span class="text-sm text-ink-gray-7">Subscription Active</span>
</div>
<Button variant="ghost" theme="red">Cancel Subscription</Button>
</div>
</div>
</div>
</template>
<script setup>
import { Button } from 'frappe-ui'
defineProps({
subscription: {
type: Object,
required: true,
},
})
</script>

View File

@ -0,0 +1,37 @@
<template>
<div class="flex flex-col items-center justify-center py-20">
<div
class="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-gray-2"
>
<slot name="icon">
<FeatherIcon :name="icon" class="h-6 w-6 text-ink-gray-5" />
</slot>
</div>
<h3 class="mb-1 text-lg font-medium text-ink-gray-9">
{{ title }}
</h3>
<p class="mb-4 text-sm text-ink-gray-5">
{{ description }}
</p>
<slot name="action" />
</div>
</template>
<script setup>
import { FeatherIcon } from 'frappe-ui'
defineProps({
icon: {
type: String,
default: 'inbox',
},
title: {
type: String,
default: 'No data',
},
description: {
type: String,
default: '',
},
})
</script>

View File

@ -0,0 +1,23 @@
<template>
<header class="flex items-center justify-between border-b bg-surface-white px-5 py-2.5">
<div class="flex items-center gap-2">
<slot name="left">
<h1 class="text-xl font-semibold text-ink-gray-9">
{{ title }}
</h1>
</slot>
</div>
<div class="flex items-center gap-2">
<slot name="right" />
</div>
</header>
</template>
<script setup>
defineProps({
title: {
type: String,
default: '',
},
})
</script>

View File

@ -0,0 +1,78 @@
<template>
<button
class="flex h-7 cursor-pointer items-center rounded text-ink-gray-7 duration-300 ease-in-out focus:outline-none focus:transition-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-outline-gray-3"
:class="isActive ? 'bg-surface-selected shadow-sm' : 'hover:bg-surface-gray-2'"
@click="handleClick"
>
<div
class="flex w-full items-center justify-between duration-300 ease-in-out"
:class="isCollapsed ? 'ml-[3px] p-1' : 'px-2 py-1'"
>
<div class="flex items-center truncate">
<Tooltip :text="label" placement="right" :disabled="!isCollapsed">
<slot name="icon">
<span class="grid flex-shrink-0 place-items-center">
<FeatherIcon
v-if="typeof icon === 'string'"
:name="icon"
class="size-4 text-ink-gray-7"
/>
<component v-else :is="icon" class="size-4 text-ink-gray-7" />
</span>
</slot>
</Tooltip>
<span
class="flex-1 flex-shrink-0 truncate text-sm duration-300 ease-in-out"
:class="
isCollapsed
? 'ml-0 w-0 overflow-hidden opacity-0'
: 'ml-2 w-auto opacity-100'
"
>
{{ label }}
</span>
</div>
<slot name="right" />
</div>
</button>
</template>
<script setup>
import { Tooltip, FeatherIcon } from 'frappe-ui'
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const props = defineProps({
icon: {
type: [Object, String, Function],
},
label: {
type: String,
default: '',
},
to: {
type: [Object, String],
default: '',
},
isCollapsed: {
type: Boolean,
default: false,
},
})
function handleClick() {
if (!props.to) return
if (typeof props.to === 'object') {
router.push(props.to)
} else {
router.push({ name: props.to })
}
}
const isActive = computed(() => {
return route.name === props.to
})
</script>

View File

@ -0,0 +1,28 @@
<template>
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="2"
y="5"
width="20"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M2 10H22"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M6 15H10"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</template>

View File

@ -0,0 +1,18 @@
<template>
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 11H5M19 11C20.1046 11 21 11.8954 21 13V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V13C3 11.8954 3.89543 11 5 11M19 11V9C19 7.89543 18.1046 7 17 7M5 11V9C5 7.89543 5.89543 7 7 7M7 7V5C7 3.89543 7.89543 3 9 3H15C16.1046 3 17 3.89543 17 5V7M7 7H17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="8" cy="16" r="1" fill="currentColor" />
<circle cx="12" cy="16" r="1" fill="currentColor" />
<circle cx="16" cy="16" r="1" fill="currentColor" />
</svg>
</template>

View File

@ -0,0 +1,22 @@
<template>
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M19.4 15C19.2669 15.3016 19.2272 15.6362 19.286 15.9606C19.3448 16.285 19.4995 16.5843 19.73 16.82L19.79 16.88C19.976 17.0657 20.1235 17.2863 20.2241 17.5291C20.3248 17.7719 20.3766 18.0322 20.3766 18.295C20.3766 18.5578 20.3248 18.8181 20.2241 19.0609C20.1235 19.3037 19.976 19.5243 19.79 19.71C19.6043 19.896 19.3837 20.0435 19.1409 20.1441C18.8981 20.2448 18.6378 20.2966 18.375 20.2966C18.1122 20.2966 17.8519 20.2448 17.6091 20.1441C17.3663 20.0435 17.1457 19.896 16.96 19.71L16.9 19.65C16.6643 19.4195 16.365 19.2648 16.0406 19.206C15.7162 19.1472 15.3816 19.1869 15.08 19.32C14.7842 19.4468 14.532 19.6572 14.3543 19.9255C14.1766 20.1938 14.0813 20.5082 14.08 20.83V21C14.08 21.5304 13.8693 22.0391 13.4942 22.4142C13.1191 22.7893 12.6104 23 12.08 23C11.5496 23 11.0409 22.7893 10.6658 22.4142C10.2907 22.0391 10.08 21.5304 10.08 21V20.91C10.0723 20.579 9.96512 20.258 9.77251 19.9887C9.5799 19.7194 9.31074 19.5143 9 19.4C8.69838 19.2669 8.36381 19.2272 8.03941 19.286C7.71502 19.3448 7.41568 19.4995 7.18 19.73L7.12 19.79C6.93425 19.976 6.71368 20.1235 6.47088 20.2241C6.22808 20.3248 5.96783 20.3766 5.705 20.3766C5.44217 20.3766 5.18192 20.3248 4.93912 20.2241C4.69632 20.1235 4.47575 19.976 4.29 19.79C4.10405 19.6043 3.95653 19.3837 3.85588 19.1409C3.75523 18.8981 3.70343 18.6378 3.70343 18.375C3.70343 18.1122 3.75523 17.8519 3.85588 17.6091C3.95653 17.3663 4.10405 17.1457 4.29 16.96L4.35 16.9C4.58054 16.6643 4.73519 16.365 4.794 16.0406C4.85282 15.7162 4.81312 15.3816 4.68 15.08C4.55324 14.7842 4.34276 14.532 4.07447 14.3543C3.80618 14.1766 3.49179 14.0813 3.17 14.08H3C2.46957 14.08 1.96086 13.8693 1.58579 13.4942C1.21071 13.1191 1 12.6104 1 12.08C1 11.5496 1.21071 11.0409 1.58579 10.6658C1.96086 10.2907 2.46957 10.08 3 10.08H3.09C3.42099 10.0723 3.742 9.96512 4.0113 9.77251C4.28059 9.5799 4.48572 9.31074 4.6 9C4.73312 8.69838 4.77282 8.36381 4.714 8.03941C4.65519 7.71502 4.50054 7.41568 4.27 7.18L4.21 7.12C4.02405 6.93425 3.87653 6.71368 3.77588 6.47088C3.67523 6.22808 3.62343 5.96783 3.62343 5.705C3.62343 5.44217 3.67523 5.18192 3.77588 4.93912C3.87653 4.69632 4.02405 4.47575 4.21 4.29C4.39575 4.10405 4.61632 3.95653 4.85912 3.85588C5.10192 3.75523 5.36217 3.70343 5.625 3.70343C5.88783 3.70343 6.14808 3.75523 6.39088 3.85588C6.63368 3.95653 6.85425 4.10405 7.04 4.29L7.1 4.35C7.33568 4.58054 7.63502 4.73519 7.95941 4.794C8.28381 4.85282 8.61838 4.81312 8.92 4.68H9C9.29577 4.55324 9.54802 4.34276 9.72569 4.07447C9.90337 3.80618 9.99872 3.49179 10 3.17V3C10 2.46957 10.2107 1.96086 10.5858 1.58579C10.9609 1.21071 11.4696 1 12 1C12.5304 1 13.0391 1.21071 13.4142 1.58579C13.7893 1.96086 14 2.46957 14 3V3.09C14.0013 3.41179 14.0966 3.72618 14.2743 3.99447C14.452 4.26276 14.7042 4.47324 15 4.6C15.3016 4.73312 15.6362 4.77282 15.9606 4.714C16.285 4.65519 16.5843 4.50054 16.82 4.27L16.88 4.21C17.0657 4.02405 17.2863 3.87653 17.5291 3.77588C17.7719 3.67523 18.0322 3.62343 18.295 3.62343C18.5578 3.62343 18.8181 3.67523 19.0609 3.77588C19.3037 3.87653 19.5243 4.02405 19.71 4.21C19.896 4.39575 20.0435 4.61632 20.1441 4.85912C20.2448 5.10192 20.2966 5.36217 20.2966 5.625C20.2966 5.88783 20.2448 6.14808 20.1441 6.39088C20.0435 6.63368 19.896 6.85425 19.71 7.04L19.65 7.1C19.4195 7.33568 19.2648 7.63502 19.206 7.95941C19.1472 8.28381 19.1869 8.61838 19.32 8.92V9C19.4468 9.29577 19.6572 9.54802 19.9255 9.72569C20.1938 9.90337 20.5082 9.99872 20.83 10H21C21.5304 10 22.0391 10.2107 22.4142 10.5858C22.7893 10.9609 23 11.4696 23 12C23 12.5304 22.7893 13.0391 22.4142 13.4142C22.0391 13.7893 21.5304 14 21 14H20.91C20.5882 14.0013 20.2738 14.0966 20.0055 14.2743C19.7372 14.452 19.5268 14.7042 19.4 15Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>

View File

@ -0,0 +1,25 @@
<template>
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="2"
y="3"
width="20"
height="18"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M2 9H22"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="5" cy="6" r="1" fill="currentColor" />
<circle cx="8" cy="6" r="1" fill="currentColor" />
<circle cx="11" cy="6" r="1" fill="currentColor" />
</svg>
</template>

View File

@ -0,0 +1,23 @@
<template>
<header class="flex items-center justify-between border-b bg-surface-white px-5 py-2.5">
<div class="flex items-center gap-2">
<slot name="left">
<h1 class="text-xl font-semibold text-ink-gray-9">
{{ title }}
</h1>
</slot>
</div>
<div class="flex items-center gap-2">
<slot name="right" />
</div>
</header>
</template>
<script setup>
defineProps({
title: {
type: String,
default: '',
},
})
</script>

View File

@ -0,0 +1,110 @@
<template>
<div
class="relative flex h-full flex-col justify-between transition-all duration-300 ease-in-out"
:class="isSidebarCollapsed ? 'w-12' : 'w-[220px]'"
>
<div class="p-2">
<div
class="flex items-center gap-2 px-2 py-1.5 transition-all duration-300 ease-in-out"
:class="isSidebarCollapsed ? 'justify-center' : ''"
>
<PortalLogo class="h-6 w-6 flex-shrink-0" />
<span
v-if="!isSidebarCollapsed"
class="text-lg font-semibold text-ink-gray-9"
>
Cloud Portal
</span>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<nav class="flex flex-col gap-0.5 px-2">
<SidebarLink
v-for="link in navLinks"
:key="link.label"
:icon="link.icon"
:label="link.label"
:to="link.to"
:isCollapsed="isSidebarCollapsed"
/>
</nav>
</div>
<div class="m-2 flex flex-col gap-1">
<!-- User info -->
<div
v-if="!isSidebarCollapsed && session.user"
class="flex items-center gap-2 px-2 py-1.5 mb-2 text-sm text-ink-gray-7 truncate"
>
<FeatherIcon name="user" class="h-4 w-4 flex-shrink-0" />
<span class="truncate">{{ session.user }}</span>
</div>
<!-- Logout button -->
<SidebarLink
label="Logout"
:isCollapsed="isSidebarCollapsed"
@click="handleLogout"
>
<template #icon>
<span class="grid h-4 w-4 flex-shrink-0 place-items-center">
<FeatherIcon name="log-out" class="h-4 w-4 text-ink-gray-7" />
</span>
</template>
</SidebarLink>
<!-- Collapse button -->
<SidebarLink
:label="isSidebarCollapsed ? 'Expand' : 'Collapse'"
:isCollapsed="isSidebarCollapsed"
@click="isSidebarCollapsed = !isSidebarCollapsed"
>
<template #icon>
<span class="grid h-4 w-4 flex-shrink-0 place-items-center">
<FeatherIcon
name="chevrons-left"
class="h-4 w-4 text-ink-gray-7 duration-300 ease-in-out"
:class="{ '[transform:rotateY(180deg)]': isSidebarCollapsed }"
/>
</span>
</template>
</SidebarLink>
</div>
</div>
</template>
<script setup>
import { useStorage } from '@vueuse/core'
import { FeatherIcon } from 'frappe-ui'
import SidebarLink from '@/components/Common/SidebarLink.vue'
import PortalLogo from '@/components/Icons/PortalLogo.vue'
import SitesIcon from '@/components/Icons/SitesIcon.vue'
import BillingIcon from '@/components/Icons/BillingIcon.vue'
import SettingsIcon from '@/components/Icons/SettingsIcon.vue'
import { session } from '@/session'
function handleLogout() {
session.logout()
}
const isSidebarCollapsed = useStorage('portal-sidebar-collapsed', false)
const navLinks = [
{
label: 'Sites',
icon: SitesIcon,
to: 'Sites',
},
{
label: 'Billing',
icon: BillingIcon,
to: 'Billing',
},
{
label: 'Settings',
icon: SettingsIcon,
to: 'Settings',
},
]
</script>

View File

@ -0,0 +1,14 @@
<template>
<div class="flex h-screen w-screen">
<div class="h-full border-r bg-surface-menu-bar">
<AppSidebar />
</div>
<div class="flex flex-1 flex-col h-full overflow-auto bg-surface-white">
<slot />
</div>
</div>
</template>
<script setup>
import AppSidebar from '@/components/Layouts/AppSidebar.vue'
</script>

View File

@ -0,0 +1,340 @@
<template>
<Dialog
v-model="show"
:options="{
title: createdContainer ? 'Creating Container' : 'Create New Container',
size: 'lg',
}"
>
<template #body-content>
<!-- Creation Form -->
<div v-if="!createdContainer" class="space-y-4">
<FormControl
label="Container Name"
v-model="form.name"
placeholder="my-container"
description="3-63 chars, starts with letter, lowercase letters/numbers/hyphens only"
:disabled="creating"
/>
<FormControl
label="Image"
type="select"
v-model="form.image"
:options="imageOptions"
:disabled="creating"
/>
<!-- Trial mode -->
<div class="flex items-center justify-between rounded-lg border border-surface-gray-3 px-4 py-3">
<div>
<p class="text-sm font-medium text-ink-gray-9">Trial mode</p>
<p class="text-xs text-ink-gray-5">Demo fixed 1 user, limited period</p>
</div>
<input
type="checkbox"
v-model="form.trial"
:disabled="creating"
class="h-4 w-4 rounded border-gray-300 text-blue-600 cursor-pointer"
@change="form.trial && (userCount = [1])"
/>
</div>
<!-- User count slider -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-ink-gray-7">Number of Users</label>
<Badge class="!rounded-sm" :theme="form.trial ? 'gray' : 'blue'">
{{ form.trial ? 1 : userCount[0] }} {{ form.trial ? 'user (Trial)' : userCount[0] === 1 ? 'user' : 'users' }}
</Badge>
</div>
<Slider
v-model="userCount"
:min="1"
:max="100"
:disabled="creating || form.trial"
class="w-full"
:class="{ 'opacity-40': form.trial }"
/>
<div class="flex justify-between mt-1 text-xs text-ink-gray-4">
<span>1</span>
<span>50</span>
<span>100</span>
</div>
</div>
<div v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-600">
{{ error }}
</div>
</div>
<!-- Progress View -->
<div v-else class="space-y-4">
<div class="flex items-center gap-3 p-4 rounded-lg bg-surface-gray-1">
<div
class="flex h-12 w-12 items-center justify-center rounded-full"
:class="statusBgClass"
>
<LoadingIndicator v-if="isWaiting" class="h-6 w-6" :class="statusIconClass" />
<FeatherIcon v-else :name="statusIcon" class="h-6 w-6" :class="statusIconClass" />
</div>
<div class="flex-1">
<p class="font-medium text-ink-gray-9">{{ createdContainer.container_name }}</p>
<p class="text-sm" :class="statusTextClass">{{ statusMessage }}</p>
</div>
</div>
<!-- Progress Bar -->
<div v-if="isWaiting" class="space-y-2">
<div class="h-2 bg-surface-gray-2 rounded-full overflow-hidden">
<div
class="h-full bg-blue-500 rounded-full transition-all duration-500"
:style="{ width: progressWidth }"
/>
</div>
<p class="text-xs text-ink-gray-5 text-center">{{ progressText }}</p>
</div>
<!-- Error Message -->
<div v-if="createdContainer.status === 'Error'" class="rounded-lg bg-red-50 p-3 text-sm text-red-600">
{{ createdContainer.error_message || 'Container creation failed' }}
</div>
</div>
</template>
<template #actions>
<div class="flex justify-end gap-2">
<!-- Form Actions -->
<template v-if="!createdContainer">
<Button variant="subtle" @click="closeModal" :disabled="creating">Cancel</Button>
<Button variant="solid" @click="createContainer" :loading="creating">
Create Container
</Button>
</template>
<!-- Progress Actions -->
<template v-else>
<Button
v-if="isWaiting"
variant="subtle"
@click="closeModal"
>
Run in Background
</Button>
<Button
v-else
variant="solid"
@click="finishAndClose"
>
{{ createdContainer.status === 'Error' ? 'Close' : 'Done' }}
</Button>
</template>
</div>
</template>
</Dialog>
</template>
<script setup>
import { reactive, computed, ref, watch, onUnmounted } from 'vue'
import { Dialog, Button, FormControl, LoadingIndicator, FeatherIcon, createResource, Slider, Badge } from 'frappe-ui'
import { getStatusStyles } from '@/utils/statusStyles'
const POLL_INTERVAL = 2000 // 2 seconds
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue', 'created'])
const show = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const creating = ref(false)
const error = ref('')
const createdContainer = ref(null)
const progress = ref(0)
let pollInterval = null
let progressInterval = null
const form = reactive({
name: '',
image: 'alpine/edge',
trial: false,
})
const userCount = ref([10])
const imageOptions = [
{ label: 'Alpine Edge', value: 'alpine/edge' },
{ label: 'Ubuntu 22.04', value: 'ubuntu/22.04' },
{ label: 'Ubuntu 24.04', value: 'ubuntu/24.04' },
{ label: 'Debian 12', value: 'debian/12' },
]
// Status helpers
const isWaiting = computed(() =>
createdContainer.value &&
['Creating', 'Pending'].includes(createdContainer.value.status)
)
const statusStyles = computed(() =>
getStatusStyles(createdContainer.value?.status)
)
const statusBgClass = computed(() => statusStyles.value.bgClass)
const statusIconClass = computed(() => statusStyles.value.iconClass)
const statusTextClass = computed(() => statusStyles.value.textClass)
const statusIcon = computed(() => statusStyles.value.icon)
const statusMessage = computed(() => {
if (!createdContainer.value) return ''
switch (createdContainer.value.status) {
case 'Pending': return 'Queued for creation...'
case 'Creating': return 'Creating container...'
case 'Running': return 'Container is ready!'
case 'Error': return 'Creation failed'
default: return createdContainer.value.status
}
})
const progressWidth = computed(() => `${Math.min(progress.value, 95)}%`)
const progressText = computed(() => {
if (progress.value < 30) return 'Initializing...'
if (progress.value < 60) return 'Setting up container...'
if (progress.value < 90) return 'Almost ready...'
return 'Finalizing...'
})
// Resources
const createContainerResource = createResource({
url: 'cloud_portal.cloud_portal.doctype.cloud_container.cloud_container.create_user_container',
onSuccess(data) {
creating.value = false
if (data.success) {
createdContainer.value = {
name: data.name,
container_name: data.container_name,
status: 'Creating',
}
startPolling(data.name)
startProgressAnimation()
} else {
error.value = data.message || 'Failed to create container'
}
},
onError(err) {
creating.value = false
error.value = err.messages?.[0] || err.message || 'Failed to create container'
}
})
const containerStatusResource = createResource({
url: 'frappe.client.get_value',
onSuccess(data) {
if (data && createdContainer.value) {
createdContainer.value.status = data.status
createdContainer.value.error_message = data.error_message
// Stop polling when done
if (!['Creating', 'Pending'].includes(data.status)) {
stopPolling()
progress.value = 100
}
}
},
})
function createContainer() {
const name = form.name.trim()
if (!name || !/^[a-z][a-z0-9-]{2,62}$/.test(name)) {
error.value = 'Name must be 3-63 chars, start with letter, only lowercase letters, numbers, hyphens'
return
}
error.value = ''
creating.value = true
createContainerResource.fetch({
container_name: name,
image: form.image,
container_type: 'container',
max_users: form.trial ? 1 : userCount.value[0],
trial: form.trial ? 1 : 0,
})
}
function startPolling(containerName) {
pollInterval = setInterval(() => {
containerStatusResource.fetch({
doctype: 'Cloud Container',
filters: JSON.stringify({ name: containerName }),
fieldname: ['status', 'error_message'],
})
}, POLL_INTERVAL)
}
function startProgressAnimation() {
progress.value = 0
progressInterval = setInterval(() => {
if (progress.value < 90) {
progress.value += Math.random() * 10 + 2
}
}, 500)
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
if (progressInterval) {
clearInterval(progressInterval)
progressInterval = null
}
}
function resetForm() {
form.name = ''
form.image = 'alpine/edge'
form.trial = false
userCount.value = [10]
error.value = ''
createdContainer.value = null
progress.value = 0
}
function closeModal() {
stopPolling()
show.value = false
// Emit created if container was started
if (createdContainer.value) {
emit('created')
}
// Reset after animation
setTimeout(resetForm, 300)
}
function finishAndClose() {
emit('created')
show.value = false
setTimeout(resetForm, 300)
}
// Cleanup on unmount
onUnmounted(() => {
stopPolling()
})
// Reset when modal closes externally
watch(show, (newVal) => {
if (!newVal) {
stopPolling()
setTimeout(resetForm, 300)
}
})
</script>

View File

@ -0,0 +1,55 @@
<template>
<div
class="cursor-pointer rounded-lg border bg-white p-5 transition-shadow hover:shadow-md"
@click="$emit('click')"
>
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg"
:class="statusIconBg"
>
<FeatherIcon :name="statusIcon" class="h-5 w-5" :class="statusIconColor" />
</div>
<div>
<h3 class="font-medium text-ink-gray-9">{{ site.container_name || site.name }}</h3>
<p class="text-sm text-ink-gray-5">{{ site.image || site.plan }}</p>
</div>
</div>
<Badge
:variant="badgeVariant"
:label="site.status"
/>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex flex-col gap-1">
<span v-if="site.ip_address" class="text-ink-gray-7 font-mono">{{ site.ip_address }}</span>
<span class="text-ink-gray-5">{{ site.created }}</span>
</div>
<FeatherIcon name="chevron-right" class="h-4 w-4 text-ink-gray-4" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Badge, FeatherIcon } from 'frappe-ui'
import { getStatusStyles } from '@/utils/statusStyles'
const props = defineProps({
site: {
type: Object,
required: true,
},
})
defineEmits(['click'])
// Computed properties using centralized status styles
const statusStyles = computed(() => getStatusStyles(props.site.status))
const badgeVariant = computed(() => statusStyles.value.badgeVariant)
const statusIcon = computed(() => statusStyles.value.icon)
const statusIconBg = computed(() => statusStyles.value.bgClass)
const statusIconColor = computed(() => statusStyles.value.iconClass)
</script>

5
frontend/src/index.css Normal file
View File

@ -0,0 +1,5 @@
@import 'frappe-ui/style.css';
@tailwind base;
@tailwind components;
@tailwind utilities;

63
frontend/src/main.js Normal file
View File

@ -0,0 +1,63 @@
import './index.css'
import { createApp } from 'vue'
import router from './router'
import App from './App.vue'
import {
FrappeUI,
Button,
Input,
TextInput,
FormControl,
ErrorMessage,
Dialog,
Alert,
Badge,
setConfig,
frappeRequest,
FeatherIcon,
} from 'frappe-ui'
const globalComponents = {
Button,
TextInput,
Input,
FormControl,
ErrorMessage,
Dialog,
Alert,
Badge,
FeatherIcon,
}
const app = createApp(App)
setConfig('resourceFetcher', frappeRequest)
app.use(FrappeUI, {
socketio: false, // Disable WebSocket - not supported by proxy
})
app.use(router)
for (const key in globalComponents) {
app.component(key, globalComponents[key])
}
if (import.meta.env.DEV) {
frappeRequest({
url: '/api/method/cloud_portal.www.portal.get_context_for_dev',
})
.then((values) => {
for (const key in values) {
window[key] = values[key]
}
})
.catch((e) => {
console.warn('Failed to fetch dev context:', e)
})
.finally(() => {
app.mount('#app')
})
} else {
app.mount('#app')
}

View File

@ -0,0 +1,106 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader title="Billing">
<template #right>
<Button variant="subtle" @click="router.push({ name: 'Invoices' })">
View All Invoices
</Button>
</template>
</LayoutHeader>
<div class="flex-1 overflow-auto p-5">
<div class="grid gap-6 lg:grid-cols-3">
<div class="lg:col-span-2 space-y-6">
<SubscriptionCard :subscription="subscription" />
<div class="rounded-lg border p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-ink-gray-9">Recent Invoices</h2>
<Button
variant="ghost"
size="sm"
@click="router.push({ name: 'Invoices' })"
>
View All
</Button>
</div>
<InvoiceList :invoices="recentInvoices" />
</div>
</div>
<div class="space-y-6">
<div class="rounded-lg border p-5">
<h2 class="text-lg font-semibold text-ink-gray-9 mb-4">Payment Method</h2>
<div class="flex items-center gap-3 rounded-lg bg-surface-gray-1 px-4 py-3">
<div class="flex h-10 w-14 items-center justify-center rounded bg-white border">
<FeatherIcon name="credit-card" class="h-5 w-5 text-ink-gray-5" />
</div>
<div>
<p class="font-medium text-ink-gray-9">Visa ending in 4242</p>
<p class="text-sm text-ink-gray-5">Expires 12/25</p>
</div>
</div>
<Button class="w-full mt-4" variant="subtle">
Update Payment Method
</Button>
</div>
<div class="rounded-lg border p-5">
<h2 class="text-lg font-semibold text-ink-gray-9 mb-4">Billing Address</h2>
<div class="text-sm text-ink-gray-7">
<p>Acme Corporation</p>
<p>123 Business Street</p>
<p>Mumbai, Maharashtra 400001</p>
<p>India</p>
</div>
<Button class="w-full mt-4" variant="subtle">
Update Address
</Button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button, FeatherIcon } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import SubscriptionCard from '@/components/Billing/SubscriptionCard.vue'
import InvoiceList from '@/components/Billing/InvoiceList.vue'
const router = useRouter()
// Mock data
const subscription = ref({
plan: 'Business',
price: '$99',
period: 'month',
nextBillingDate: 'February 15, 2024',
sites: 3,
maxSites: 10,
})
const recentInvoices = ref([
{
id: 'INV-2024-001',
date: 'January 15, 2024',
amount: '$99.00',
status: 'Paid',
},
{
id: 'INV-2023-012',
date: 'December 15, 2023',
amount: '$99.00',
status: 'Paid',
},
{
id: 'INV-2023-011',
date: 'November 15, 2023',
amount: '$99.00',
status: 'Paid',
},
])
</script>

View File

@ -0,0 +1,117 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader>
<template #left>
<div class="flex items-center gap-3">
<Button variant="ghost" @click="router.push({ name: 'Billing' })">
<FeatherIcon name="arrow-left" class="h-4 w-4" />
</Button>
<h1 class="text-xl font-semibold text-ink-gray-9">Invoices</h1>
</div>
</template>
</LayoutHeader>
<div class="flex-1 overflow-auto p-5">
<div class="rounded-lg border">
<table class="w-full">
<thead>
<tr class="border-b bg-surface-gray-1">
<th class="px-4 py-3 text-left text-sm font-medium text-ink-gray-5">
Invoice
</th>
<th class="px-4 py-3 text-left text-sm font-medium text-ink-gray-5">
Date
</th>
<th class="px-4 py-3 text-left text-sm font-medium text-ink-gray-5">
Amount
</th>
<th class="px-4 py-3 text-left text-sm font-medium text-ink-gray-5">
Status
</th>
<th class="px-4 py-3 text-right text-sm font-medium text-ink-gray-5">
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="invoice in invoices"
:key="invoice.id"
class="border-b last:border-0"
>
<td class="px-4 py-3">
<span class="font-medium text-ink-gray-9">{{ invoice.id }}</span>
</td>
<td class="px-4 py-3 text-sm text-ink-gray-7">
{{ invoice.date }}
</td>
<td class="px-4 py-3 text-sm font-medium text-ink-gray-9">
{{ invoice.amount }}
</td>
<td class="px-4 py-3">
<Badge
:variant="invoice.status === 'Paid' ? 'success' : 'warning'"
:label="invoice.status"
/>
</td>
<td class="px-4 py-3 text-right">
<Button variant="ghost" size="sm">
<FeatherIcon name="download" class="h-4 w-4" />
</Button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button, Badge, FeatherIcon } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
const router = useRouter()
// Mock data
const invoices = ref([
{
id: 'INV-2024-001',
date: 'January 15, 2024',
amount: '$99.00',
status: 'Paid',
},
{
id: 'INV-2023-012',
date: 'December 15, 2023',
amount: '$99.00',
status: 'Paid',
},
{
id: 'INV-2023-011',
date: 'November 15, 2023',
amount: '$99.00',
status: 'Paid',
},
{
id: 'INV-2023-010',
date: 'October 15, 2023',
amount: '$99.00',
status: 'Paid',
},
{
id: 'INV-2023-009',
date: 'September 15, 2023',
amount: '$99.00',
status: 'Paid',
},
{
id: 'INV-2023-008',
date: 'August 15, 2023',
amount: '$99.00',
status: 'Paid',
},
])
</script>

View File

@ -0,0 +1,107 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Cloud Portal
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Sign in to your account
</p>
</div>
<form class="mt-8 space-y-6" @submit.prevent="login">
<div class="rounded-md shadow-sm space-y-4">
<FormControl
label="Email"
type="email"
v-model="form.email"
placeholder="you@example.com"
:disabled="loading"
required
/>
<FormControl
label="Password"
type="password"
v-model="form.password"
placeholder="Password"
:disabled="loading"
required
/>
</div>
<div v-if="error" class="rounded-md bg-red-50 p-4">
<p class="text-sm text-red-700">{{ error }}</p>
</div>
<div>
<Button
type="submit"
variant="solid"
class="w-full"
:loading="loading"
>
Sign in
</Button>
</div>
<div class="text-center">
<router-link
:to="{ name: 'Register' }"
class="text-sm text-blue-600 hover:text-blue-500"
>
Don't have an account? Sign up
</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Button, FormControl, createResource } from 'frappe-ui'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const error = ref('')
const form = reactive({
email: '',
password: '',
})
const loginResource = createResource({
url: 'login',
makeParams() {
return {
usr: form.email,
pwd: form.password,
}
},
onSuccess() {
loading.value = false
error.value = ''
const redirectPath = route.query.redirect || '/sites'
window.location.href = '/portal' + redirectPath
},
onError(err) {
loading.value = false
error.value = err.messages?.[0] || err.message || 'Invalid email or password'
}
})
function login() {
if (!form.email || !form.password) {
error.value = 'Please enter email and password'
return
}
error.value = ''
loading.value = true
loginResource.submit()
}
</script>

View File

@ -0,0 +1,21 @@
<template>
<div class="flex h-full flex-col items-center justify-center">
<div class="text-center">
<h1 class="text-6xl font-bold text-ink-gray-3 mb-4">404</h1>
<h2 class="text-2xl font-semibold text-ink-gray-9 mb-2">Page Not Found</h2>
<p class="text-ink-gray-5 mb-6">
The page you're looking for doesn't exist or has been moved.
</p>
<Button variant="solid" @click="router.push({ name: 'Sites' })">
Go to Sites
</Button>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { Button } from 'frappe-ui'
const router = useRouter()
</script>

View File

@ -0,0 +1,217 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Cloud Portal
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Create your account
</p>
</div>
<form class="mt-8 space-y-6" @submit.prevent="register">
<div class="rounded-md shadow-sm space-y-4">
<!-- VOEN field with lookup -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
VOEN (ИНН) <span class="text-red-500">*</span>
</label>
<div class="flex gap-2">
<input
type="text"
v-model="form.voen"
placeholder="0000000000"
:disabled="loading || voenLoading"
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
@keydown.enter.prevent="lookupVoen"
/>
<Button
type="button"
variant="subtle"
:loading="voenLoading"
:disabled="!form.voen || loading"
@click="lookupVoen"
>
Lookup
</Button>
</div>
<div v-if="voenInfo" class="mt-2 rounded-md bg-blue-50 p-3">
<p class="text-sm font-medium text-blue-800">{{ voenInfo.name }}</p>
<p class="text-xs text-blue-600 mt-0.5">{{ voenInfo.organization_type }}</p>
<div class="flex gap-3 mt-1">
<span v-if="voenInfo.active" class="text-xs text-green-600">Active</span>
<span v-if="voenInfo.vat_payer" class="text-xs text-blue-600">VAT Payer</span>
</div>
</div>
<p v-if="voenError" class="mt-1 text-sm text-red-600">{{ voenError }}</p>
</div>
<FormControl
label="Full Name"
type="text"
v-model="form.fullName"
placeholder="John Doe"
:disabled="loading"
required
/>
<FormControl
label="Email"
type="email"
v-model="form.email"
placeholder="you@example.com"
:disabled="loading"
required
/>
<FormControl
label="Password"
type="password"
v-model="form.password"
placeholder="Password (min 6 characters)"
:disabled="loading"
required
/>
<FormControl
label="Confirm Password"
type="password"
v-model="form.confirmPassword"
placeholder="Confirm password"
:disabled="loading"
required
/>
</div>
<div v-if="error" class="rounded-md bg-red-50 p-4">
<p class="text-sm text-red-700">{{ error }}</p>
</div>
<div v-if="success" class="rounded-md bg-green-50 p-4">
<p class="text-sm text-green-700">{{ success }}</p>
</div>
<div>
<Button
type="submit"
variant="solid"
class="w-full"
:loading="loading"
>
Create Account
</Button>
</div>
<div class="text-center">
<router-link
:to="{ name: 'Login' }"
class="text-sm text-blue-600 hover:text-blue-500"
>
Already have an account? Sign in
</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { Button, FormControl, createResource } from 'frappe-ui'
const loading = ref(false)
const error = ref('')
const success = ref('')
const voenLoading = ref(false)
const voenError = ref('')
const voenInfo = ref(null)
const form = reactive({
voen: '',
fullName: '',
email: '',
password: '',
confirmPassword: '',
})
const voenResource = createResource({
url: 'cloud_portal.api.voen.lookup',
onSuccess(data) {
voenLoading.value = false
if (data.success) {
voenInfo.value = data
voenError.value = ''
if (!form.fullName) {
form.fullName = data.name
}
} else {
voenInfo.value = null
voenError.value = data.message || 'Organization not found'
}
},
onError(err) {
voenLoading.value = false
voenInfo.value = null
voenError.value = err.messages?.[0] || err.message || 'Lookup failed'
},
})
function lookupVoen() {
if (!form.voen) return
voenLoading.value = true
voenError.value = ''
voenInfo.value = null
voenResource.fetch({ voen: form.voen })
}
const registerResource = createResource({
url: 'cloud_portal.api.auth.register_user',
onSuccess(data) {
loading.value = false
if (data.success) {
success.value = 'Account created! Redirecting...'
error.value = ''
setTimeout(() => {
window.location.href = '/portal/sites'
}, 500)
} else {
error.value = data.message || 'Registration failed'
}
},
onError(err) {
loading.value = false
error.value = err.messages?.[0] || err.message || 'Registration failed'
},
})
function register() {
if (!form.voen) {
error.value = 'Please enter your VOEN (ИНН)'
return
}
if (!form.fullName || !form.email || !form.password) {
error.value = 'Please fill in all fields'
return
}
if (form.password.length < 6) {
error.value = 'Password must be at least 6 characters'
return
}
if (form.password !== form.confirmPassword) {
error.value = 'Passwords do not match'
return
}
error.value = ''
success.value = ''
loading.value = true
registerResource.fetch({
full_name: form.fullName,
email: form.email,
password: form.password,
voen: form.voen,
})
}
</script>

View File

@ -0,0 +1,170 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader title="Settings" />
<div class="flex-1 overflow-auto p-5">
<div class="max-w-3xl">
<div class="mb-6 flex gap-2 border-b">
<button
v-for="tab in tabs"
:key="tab.name"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="
activeTab === tab.name
? 'border-b-2 border-ink-gray-9 text-ink-gray-9'
: 'text-ink-gray-5 hover:text-ink-gray-7'
"
@click="activeTab = tab.name"
>
{{ tab.label }}
</button>
</div>
<div v-if="activeTab === 'profile'" class="space-y-6">
<div class="rounded-lg border p-5">
<h2 class="text-lg font-semibold text-ink-gray-9 mb-4">Profile</h2>
<div class="space-y-4">
<div class="flex items-center gap-4">
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-surface-gray-2 text-2xl font-semibold text-ink-gray-5"
>
JD
</div>
<Button variant="subtle">Change Avatar</Button>
</div>
<FormControl label="Full Name" v-model="profile.name" />
<FormControl label="Email" v-model="profile.email" type="email" />
<FormControl label="Phone" v-model="profile.phone" type="tel" />
</div>
<div class="mt-6 flex justify-end">
<Button variant="solid">Save Changes</Button>
</div>
</div>
</div>
<div v-if="activeTab === 'security'" class="space-y-6">
<div class="rounded-lg border p-5">
<h2 class="text-lg font-semibold text-ink-gray-9 mb-4">
Change Password
</h2>
<div class="space-y-4">
<FormControl
label="Current Password"
type="password"
v-model="security.currentPassword"
/>
<FormControl
label="New Password"
type="password"
v-model="security.newPassword"
/>
<FormControl
label="Confirm New Password"
type="password"
v-model="security.confirmPassword"
/>
</div>
<div class="mt-6 flex justify-end">
<Button variant="solid">Update Password</Button>
</div>
</div>
<div class="rounded-lg border p-5">
<h2 class="text-lg font-semibold text-ink-gray-9 mb-4">
Two-Factor Authentication
</h2>
<p class="text-sm text-ink-gray-5 mb-4">
Add an extra layer of security to your account by enabling two-factor
authentication.
</p>
<Button variant="subtle">Enable 2FA</Button>
</div>
</div>
<div v-if="activeTab === 'notifications'" class="space-y-6">
<div class="rounded-lg border p-5">
<h2 class="text-lg font-semibold text-ink-gray-9 mb-4">
Email Notifications
</h2>
<div class="space-y-4">
<label
v-for="option in notificationOptions"
:key="option.key"
class="flex items-center justify-between"
>
<div>
<p class="font-medium text-ink-gray-9">{{ option.label }}</p>
<p class="text-sm text-ink-gray-5">{{ option.description }}</p>
</div>
<input
type="checkbox"
v-model="notifications[option.key]"
class="h-4 w-4 rounded border-gray-300"
/>
</label>
</div>
<div class="mt-6 flex justify-end">
<Button variant="solid">Save Preferences</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { Button, FormControl } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
const activeTab = ref('profile')
const tabs = [
{ name: 'profile', label: 'Profile' },
{ name: 'security', label: 'Security' },
{ name: 'notifications', label: 'Notifications' },
]
const profile = reactive({
name: 'John Doe',
email: 'john@example.com',
phone: '+91 98765 43210',
})
const security = reactive({
currentPassword: '',
newPassword: '',
confirmPassword: '',
})
const notificationOptions = [
{
key: 'siteStatus',
label: 'Site Status Alerts',
description: 'Get notified when your site status changes',
},
{
key: 'billing',
label: 'Billing Updates',
description: 'Receive invoices and payment reminders',
},
{
key: 'security',
label: 'Security Alerts',
description: 'Get notified about security-related events',
},
{
key: 'newsletter',
label: 'Product Updates',
description: 'Receive news about new features and updates',
},
]
const notifications = reactive({
siteStatus: true,
billing: true,
security: true,
newsletter: false,
})
</script>

View File

@ -0,0 +1,146 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader>
<template #left>
<div class="flex items-center gap-3">
<Button variant="ghost" @click="router.back()">
<FeatherIcon name="arrow-left" class="h-4 w-4" />
</Button>
<div>
<h1 class="text-xl font-semibold text-ink-gray-9">{{ container.container_name || siteId }}</h1>
<div class="flex items-center gap-2">
<Badge
:variant="badgeVariant"
:label="container.status || 'Loading'"
/>
</div>
</div>
</div>
</template>
</LayoutHeader>
<div class="flex-1 overflow-auto p-5">
<div v-if="loading" class="flex items-center justify-center h-64">
<LoadingIndicator class="h-8 w-8" />
</div>
<div v-else class="grid gap-6 lg:grid-cols-3">
<div class="lg:col-span-2 space-y-6">
<div class="rounded-lg border p-5">
<h2 class="text-lg font-semibold text-ink-gray-9 mb-4">Overview</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<p class="text-sm text-ink-gray-5">Container Name</p>
<p class="text-sm font-medium text-ink-gray-9 font-mono">{{ container.container_name }}</p>
</div>
<div>
<p class="text-sm text-ink-gray-5">Image</p>
<p class="text-sm font-medium text-ink-gray-9">{{ container.image }}</p>
</div>
<div>
<p class="text-sm text-ink-gray-5">IP Address</p>
<p class="text-sm font-medium text-ink-gray-9 font-mono">{{ container.ip_address || 'Not assigned' }}</p>
</div>
<div>
<p class="text-sm text-ink-gray-5">Architecture</p>
<p class="text-sm font-medium text-ink-gray-9">{{ container.architecture || 'N/A' }}</p>
</div>
<div>
<p class="text-sm text-ink-gray-5">Created</p>
<p class="text-sm font-medium text-ink-gray-9">{{ formattedDate }}</p>
</div>
</div>
</div>
<div v-if="container.error_message" class="rounded-lg border border-red-200 bg-red-50 p-5">
<h2 class="text-lg font-semibold text-red-700 mb-2">Error</h2>
<p class="text-sm text-red-600">{{ container.error_message }}</p>
</div>
<div class="rounded-lg border p-5">
<h2 class="text-lg font-semibold text-ink-gray-9 mb-4">Container Status</h2>
<div class="flex items-center gap-4">
<div
class="flex h-16 w-16 items-center justify-center rounded-full"
:class="statusBgClass"
>
<FeatherIcon :name="statusIcon" class="h-8 w-8" :class="statusIconClass" />
</div>
<div>
<p class="text-2xl font-semibold" :class="statusTextClass">{{ container.status }}</p>
<p class="text-sm text-ink-gray-5">Incus Status: {{ container.incus_status || 'Unknown' }}</p>
</div>
</div>
</div>
</div>
<div class="space-y-6">
<div v-if="container.status === 'Creating'" class="rounded-lg border border-blue-200 bg-blue-50 p-5">
<div class="flex items-center gap-3">
<LoadingIndicator class="h-5 w-5 text-blue-600" />
<div>
<p class="font-medium text-blue-700">Creating Container</p>
<p class="text-sm text-blue-600">This may take a moment...</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Button, Badge, FeatherIcon, LoadingIndicator, createResource } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import { getStatusStyles } from '@/utils/statusStyles'
const props = defineProps({
siteId: {
type: String,
required: true,
},
})
const router = useRouter()
const container = ref({})
const loading = ref(true)
const containerResource = createResource({
url: 'frappe.client.get',
onSuccess(data) {
container.value = data
loading.value = false
},
onError(error) {
console.error('Failed to fetch container:', error)
loading.value = false
}
})
function fetchContainer() {
containerResource.fetch({
doctype: 'Cloud Container',
name: props.siteId,
})
}
// Computed properties using centralized status styles
const statusStyles = computed(() => getStatusStyles(container.value.status))
const badgeVariant = computed(() => statusStyles.value.badgeVariant)
const statusIcon = computed(() => statusStyles.value.icon)
const statusBgClass = computed(() => statusStyles.value.bgClass)
const statusIconClass = computed(() => statusStyles.value.iconClass)
const statusTextClass = computed(() => statusStyles.value.textClass)
const formattedDate = computed(() => {
if (!container.value.creation) return 'N/A'
return new Date(container.value.creation).toLocaleString()
})
onMounted(() => {
fetchContainer()
})
</script>

View File

@ -0,0 +1,93 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader title="Containers">
<template #right>
<Button variant="subtle" @click="fetchContainers" :loading="containersResource.loading">
<template #prefix>
<FeatherIcon name="refresh-cw" class="h-4 w-4" />
</template>
Refresh
</Button>
<Button variant="solid" @click="showCreateModal = true">
<template #prefix>
<FeatherIcon name="plus" class="h-4 w-4" />
</template>
New Container
</Button>
</template>
</LayoutHeader>
<div class="flex-1 overflow-auto p-5">
<div v-if="loading" class="flex items-center justify-center h-64">
<LoadingIndicator class="h-8 w-8" />
</div>
<div v-else-if="containers.length" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SiteCard
v-for="container in containers"
:key="container.name"
:site="container"
@click="goToSite(container.name)"
/>
</div>
<EmptyState
v-else
icon="server"
title="No containers yet"
description="Create your first container to get started"
>
<template #action>
<Button variant="solid" @click="showCreateModal = true">
Create Container
</Button>
</template>
</EmptyState>
</div>
<CreateSiteModal v-model="showCreateModal" @created="fetchContainers" />
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Button, FeatherIcon, LoadingIndicator, createResource } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import EmptyState from '@/components/Common/EmptyState.vue'
import SiteCard from '@/components/Sites/SiteCard.vue'
import CreateSiteModal from '@/components/Sites/CreateSiteModal.vue'
const router = useRouter()
const showCreateModal = ref(false)
const containers = ref([])
const containersResource = createResource({
url: 'cloud_portal.cloud_portal.doctype.cloud_container.cloud_container.get_user_containers',
auto: false,
onSuccess(data) {
containers.value = data.map(c => ({
name: c.name,
container_name: c.container_name,
status: c.status,
image: c.image,
ip_address: c.ip_address,
created: c.creation ? new Date(c.creation).toLocaleDateString() : '',
}))
},
})
const loading = computed(() => containersResource.loading && containers.value.length === 0)
function fetchContainers() {
containersResource.fetch()
}
function goToSite(siteId) {
router.push({ name: 'SiteDetail', params: { siteId } })
}
onMounted(() => {
fetchContainers()
})
</script>

84
frontend/src/router.js Normal file
View File

@ -0,0 +1,84 @@
import { createRouter, createWebHistory } from 'vue-router'
import { session } from './session'
const routes = [
{
path: '/',
name: 'Home',
redirect: '/sites',
},
{
path: '/login',
name: 'Login',
component: () => import('@/pages/Login.vue'),
meta: { public: true },
},
{
path: '/register',
name: 'Register',
component: () => import('@/pages/Register.vue'),
meta: { public: true },
},
{
path: '/sites',
name: 'Sites',
component: () => import('@/pages/Sites.vue'),
},
{
path: '/sites/:siteId',
name: 'SiteDetail',
component: () => import('@/pages/SiteDetail.vue'),
props: true,
},
{
path: '/billing',
name: 'Billing',
component: () => import('@/pages/Billing.vue'),
},
{
path: '/billing/invoices',
name: 'Invoices',
component: () => import('@/pages/Invoices.vue'),
},
{
path: '/settings',
name: 'Settings',
component: () => import('@/pages/Settings.vue'),
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/pages/NotFound.vue'),
meta: { public: true },
},
]
const router = createRouter({
history: createWebHistory('/portal'),
routes,
})
// Navigation guard - check authentication
router.beforeEach(async (to, from, next) => {
// Public routes don't need auth
if (to.meta.public) {
next()
return
}
// Check if user is logged in
try {
await session.init()
if (session.isLoggedIn) {
next()
} else {
// Save intended destination for redirect after login
next({ name: 'Login', query: { redirect: to.fullPath } })
}
} catch (error) {
next({ name: 'Login', query: { redirect: to.fullPath } })
}
})
export default router

47
frontend/src/session.js Normal file
View File

@ -0,0 +1,47 @@
import { reactive } from 'vue'
import { call } from 'frappe-ui'
export const session = reactive({
user: null,
isLoggedIn: false,
initialized: false,
loading: true,
async init() {
if (this.initialized) {
return
}
this.loading = true
try {
const data = await call('frappe.auth.get_logged_user')
if (data && data !== 'Guest') {
this.user = data
this.isLoggedIn = true
} else {
this.user = null
this.isLoggedIn = false
}
} catch (error) {
console.error('Session init error:', error)
this.user = null
this.isLoggedIn = false
}
this.initialized = true
this.loading = false
},
async logout() {
try {
await call('logout')
this.user = null
this.isLoggedIn = false
this.initialized = false
window.location.href = '/portal/login'
} catch (error) {
console.error('Logout error:', error)
}
},
})

View File

@ -0,0 +1,59 @@
/**
* Centralized status styles for container statuses.
* Used by SiteCard.vue and SiteDetail.vue components.
*/
export const STATUS_STYLES = {
Running: {
badgeVariant: 'success',
icon: 'play-circle',
bgClass: 'bg-green-100',
iconClass: 'text-green-600',
textClass: 'text-green-600',
},
Stopped: {
badgeVariant: 'warning',
icon: 'pause-circle',
bgClass: 'bg-orange-100',
iconClass: 'text-orange-600',
textClass: 'text-orange-600',
},
Creating: {
badgeVariant: 'info',
icon: 'loader',
bgClass: 'bg-blue-100',
iconClass: 'text-blue-600',
textClass: 'text-blue-600',
},
Pending: {
badgeVariant: 'info',
icon: 'loader',
bgClass: 'bg-blue-100',
iconClass: 'text-blue-600',
textClass: 'text-blue-600',
},
Error: {
badgeVariant: 'error',
icon: 'alert-circle',
bgClass: 'bg-red-100',
iconClass: 'text-red-600',
textClass: 'text-red-600',
},
}
const DEFAULT_STYLE = {
badgeVariant: 'subtle',
icon: 'server',
bgClass: 'bg-gray-100',
iconClass: 'text-gray-600',
textClass: 'text-gray-600',
}
/**
* Get styles for a given container status.
* @param {string} status - The container status
* @returns {Object} Style object with badgeVariant, icon, bgClass, iconClass, textClass
*/
export function getStatusStyles(status) {
return STATUS_STYLES[status] || DEFAULT_STYLE
}

View File

@ -0,0 +1,17 @@
import frappeUIPreset from 'frappe-ui/tailwind'
export default {
presets: [frappeUIPreset],
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
'./node_modules/frappe-ui/src/**/*.{vue,js,ts,jsx,tsx}',
'../node_modules/frappe-ui/src/**/*.{vue,js,ts,jsx,tsx}',
'./node_modules/frappe-ui/frappe/**/*.{vue,js,ts,jsx,tsx}',
'../node_modules/frappe-ui/frappe/**/*.{vue,js,ts,jsx,tsx}',
],
theme: {
extend: {},
},
plugins: [],
}

81
frontend/vite.config.js Normal file
View File

@ -0,0 +1,81 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig(async ({ mode }) => {
const isDev = mode === 'development'
const config = {
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
optimizeDeps: {
include: ['feather-icons', 'tailwind.config.js'],
},
server: {
fs: {
allow: [path.resolve(__dirname, '..')],
},
},
}
const frappeui = await importFrappeUIPlugin(isDev, config)
config.plugins.unshift(
frappeui({
frappeProxy: true,
lucideIcons: true,
jinjaBootData: true,
buildConfig: {
indexHtmlPath: '../cloud_portal/www/portal.html',
emptyOutDir: true,
sourcemap: true,
},
}),
)
return config
})
async function importFrappeUIPlugin(isDev, config) {
if (isDev) {
try {
const fs = await import('node:fs')
const localVitePluginPath = path.resolve(__dirname, '../frappe-ui/vite')
if (fs.existsSync(localVitePluginPath)) {
const module = await import('../frappe-ui/vite')
console.info('Local frappe-ui vite plugin found, using local plugin')
config.resolve.alias = getAliases(config)
return module.default
} else {
console.warn('Local frappe-ui vite plugin not found, using npm package')
}
} catch (error) {
console.warn(
'Local frappe-ui not found, falling back to npm package:',
error.message,
)
}
}
const module = await import('frappe-ui/vite')
return module.default
}
function getAliases(config) {
return {
...config.resolve.alias,
'frappe-ui/tailwind': path.resolve(
__dirname,
'../frappe-ui/tailwind/preset.js',
),
'frappe-ui/style.css': path.resolve(
__dirname,
'../frappe-ui/src/style.css',
),
'frappe-ui/frappe': path.resolve(__dirname, '../frappe-ui/frappe/index.js'),
'frappe-ui': path.resolve(__dirname, '../frappe-ui/src/index.ts'),
}
}

2339
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff