diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md
index f087ba6..fcad907 100644
--- a/PROJECT_STATE.md
+++ b/PROJECT_STATE.md
@@ -4,7 +4,7 @@
OTB Cloud
## Current version
-v0.1.2
+v0.1.3
## Build date
2026-04-12
@@ -25,7 +25,7 @@ Portal-authenticated secure backup and storage platform for customer files, incl
- MariaDB backend
- Vault3 storage root at `/tank/backups/otb-cloud`
- Tenant-isolated storage
-- Device-defined source directories
+- User-created devices
- Immutable originals
- Derived-file processing workflow
- Search by filename and date
@@ -33,21 +33,6 @@ Portal-authenticated secure backup and storage platform for customer files, incl
- Audit logging
- Owner-approved admin support access using one-time token
-## Device organization model
-Per-tenant storage will be organized by named devices, for example:
-- laptop
-- phone
-- tablet
-- workpc
-- homepc
-
-Each device should have:
-- originals/
-- derived/
-- exports/
-- deleted/
-- tmp/
-
## Current implemented scaffold
- Flask app factory
- Main blueprint
@@ -55,23 +40,25 @@ Each device should have:
- MariaDB connection helper
- Signed handoff endpoint
- Auth-protected dashboard
-- Temporary portal base template
+- Branded OTB portal shell styling
- SQL schema file
- DB bootstrap script
- Storage bootstrap scripts
- Gunicorn systemd service on vault3
- Mintme reverse proxy in place
+- OTB Billing signed handoff working
+- Add Device flow
## Immediate next tasks
-1. Patch OTB Billing to add OTB Cloud services card
-2. Add signed handoff redirect route in OTB Billing
-3. Replace temporary portal base with shared portal template structure
-4. Build file library and upload endpoints
-5. Add upload audit logging
-6. Add first real storage browsing page
+1. Build first file library page
+2. Add upload endpoint and upload form
+3. Add upload audit logging
+4. Add zip export flow
+5. Add searchable file listing
+6. Add media processing jobs
## Notes
Original uploaded files should remain preserved and effectively read-only.
Any user-facing edits or processing outputs should create derivative files.
Admin access should require owner-issued one-time support authorization.
-This version cleans up the temporary UI while keeping the same signed handoff endpoint for OTB Billing integration.
+New tenants no longer receive default devices automatically; devices are now user-created.
diff --git a/README.md b/README.md
index cee613f..0d97089 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,11 @@
# OTB Cloud
+## v0.1.3 - 2026-04-12
+- Removed automatic default device creation for new tenants
+- Added real Add Device flow
+- Updated dashboard to show empty state when no devices exist
+- Device directories are now created only when a user adds a device
+
## v0.1.2 - 2026-04-12
- Cleaned up unauthenticated OTB Cloud pages
- Hid Dashboard/Logout navigation when not authenticated
@@ -32,49 +38,10 @@ OTB Cloud is a private portal-authenticated backup and storage platform for Outs
Primary goals:
- Secure backup and storage for documents, images, videos, and uploaded files
- Per-customer tenant isolation
-- Device-based organization (laptop, phone, tablet, workpc, homepc, etc.)
+- Device-based organization
- Immutable original uploads
- Derived file workflow for processing and edits
- Searchable file library
- Bulk upload and bulk export support
- Audit logging
- Owner-approved admin support access using one-time token workflow
-
-## Planned host and path
-- Host: vault3
-- App path: `/opt/otb_cloud`
-- Domain: `otb-cloud.outsidethebox.top`
-- Storage root: `/tank/backups/otb-cloud`
-
-## Planned backend stack
-- Flask
-- MariaDB
-- Jinja templates with shared portal base
-- Background job processing for media conversions
-- FFmpeg for video/audio processing
-- Nginx reverse proxy
-
-## Security goals
-- Portal-authenticated access only
-- No unauthenticated file access
-- Tenant-isolated storage and database access
-- Audit logging for login attempts and file actions
-- Encrypted storage at rest
-- HTTPS/TLS in transit
-- Immutable originals by default
-
-## Device model
-Each tenant may define logical upload devices such as:
-- laptop
-- phone
-- tablet
-- workpc
-- homepc
-
-Uploads are organized by source device to preserve context.
-
-## Documentation policy
-This repository uses:
-- `README.md` for version/change log summary
-- `PROJECT_STATE.md` for current status and working notes
-- `VERSION` for current version
diff --git a/VERSION b/VERSION
index 5366600..04e1946 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-v0.1.2
+v0.1.3
diff --git a/app/auth/utils.py b/app/auth/utils.py
index 29957f7..de359f1 100644
--- a/app/auth/utils.py
+++ b/app/auth/utils.py
@@ -32,6 +32,9 @@ def slugify_email(email: str) -> str:
slug = SLUG_RE.sub("-", local).strip("-")
return slug or "tenant"
+def slugify_device_name(name: str) -> str:
+ return SLUG_RE.sub("-", name.lower().strip()).strip("-") or "device"
+
def ensure_user_tenant_and_devices(email: str, portal_user_id: int):
db = get_db()
@@ -98,25 +101,14 @@ def ensure_user_tenant_and_devices(email: str, portal_user_id: int):
(user_id, slug, storage_root),
)
tenant_id = cur.lastrowid
+ create_tenant_root(slug)
else:
tenant_id = tenant["id"]
slug = tenant["slug"]
storage_root = tenant["storage_root"]
- for device_name in current_app.config["DEFAULT_DEVICE_NAMES"]:
- relative_path = f"devices/{device_name}"
- cur.execute(
- """
- INSERT IGNORE INTO devices (tenant_id, device_name, device_type, relative_path, is_active)
- VALUES (%s, %s, %s, %s, 1)
- """,
- (tenant_id, device_name, device_name, relative_path),
- )
-
db.commit()
- create_tenant_directories(slug, current_app.config["DEFAULT_DEVICE_NAMES"])
-
return {
"user_id": user_id,
"tenant_id": tenant_id,
@@ -125,11 +117,14 @@ def ensure_user_tenant_and_devices(email: str, portal_user_id: int):
"email": email,
}
-def create_tenant_directories(tenant_slug: str, device_names: list[str]):
+def create_tenant_root(tenant_slug: str):
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug
(tenant_root / "logs").mkdir(parents=True, exist_ok=True)
(tenant_root / "support").mkdir(parents=True, exist_ok=True)
+ (tenant_root / "devices").mkdir(parents=True, exist_ok=True)
- for device_name in device_names:
- for subdir in ["originals", "derived", "exports", "deleted", "tmp"]:
- (tenant_root / "devices" / device_name / subdir).mkdir(parents=True, exist_ok=True)
+def create_device_directories(tenant_slug: str, device_path: str):
+ tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug
+ base = tenant_root / device_path
+ for subdir in ["originals", "derived", "exports", "deleted", "tmp"]:
+ (base / subdir).mkdir(parents=True, exist_ok=True)
diff --git a/app/main/routes.py b/app/main/routes.py
index 38569a0..09a881b 100644
--- a/app/main/routes.py
+++ b/app/main/routes.py
@@ -1,8 +1,9 @@
from functools import wraps
-from flask import Blueprint, redirect, render_template, session, url_for
+from flask import Blueprint, flash, redirect, render_template, request, session, url_for
from app.db import get_db
+from app.auth.utils import create_device_directories, slugify_device_name
bp = Blueprint("main", __name__)
@@ -43,3 +44,92 @@ def dashboard():
tenant_slug=session.get("otb_tenant_slug"),
devices=devices,
)
+
+@bp.route("/devices/new", methods=["GET", "POST"])
+@portal_session_required
+def add_device():
+ if request.method == "GET":
+ return render_template(
+ "cloud/device_new.html",
+ user_email=session.get("otb_email"),
+ tenant_slug=session.get("otb_tenant_slug"),
+ )
+
+ device_name = (request.form.get("device_name") or "").strip()
+ device_type = (request.form.get("device_type") or "").strip()
+
+ if not device_name:
+ flash("Device name is required.", "warning")
+ return render_template(
+ "cloud/device_new.html",
+ user_email=session.get("otb_email"),
+ tenant_slug=session.get("otb_tenant_slug"),
+ device_name=device_name,
+ device_type=device_type,
+ )
+
+ if not device_type:
+ flash("Device type is required.", "warning")
+ return render_template(
+ "cloud/device_new.html",
+ user_email=session.get("otb_email"),
+ tenant_slug=session.get("otb_tenant_slug"),
+ device_name=device_name,
+ device_type=device_type,
+ )
+
+ slug = slugify_device_name(device_name)
+ relative_path = f"devices/{slug}"
+
+ db = get_db()
+
+ with db.cursor() as cur:
+ cur.execute(
+ """
+ SELECT id
+ FROM devices
+ WHERE tenant_id = %s AND device_name = %s
+ """,
+ (session["otb_tenant_id"], device_name),
+ )
+ existing = cur.fetchone()
+
+ if existing:
+ flash("A device with that name already exists.", "warning")
+ return render_template(
+ "cloud/device_new.html",
+ user_email=session.get("otb_email"),
+ tenant_slug=session.get("otb_tenant_slug"),
+ device_name=device_name,
+ device_type=device_type,
+ )
+
+ cur.execute(
+ """
+ INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active)
+ VALUES (%s, %s, %s, %s, 1)
+ """,
+ (session["otb_tenant_id"], device_name, device_type, relative_path),
+ )
+
+ cur.execute(
+ """
+ INSERT INTO audit_logs (
+ tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
+ ) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s)
+ """,
+ (
+ session["otb_tenant_id"],
+ session["otb_user_id"],
+ request.headers.get("X-Forwarded-For", request.remote_addr),
+ request.headers.get("User-Agent", ""),
+ f"Created device '{device_name}' ({device_type}) at {relative_path}",
+ ),
+ )
+
+ db.commit()
+
+ create_device_directories(session["otb_tenant_slug"], relative_path)
+
+ flash("Device added successfully.", "success")
+ return redirect(url_for("main.dashboard"))
diff --git a/app/templates/cloud/dashboard.html b/app/templates/cloud/dashboard.html
index a36e9b3..db6fb03 100644
--- a/app/templates/cloud/dashboard.html
+++ b/app/templates/cloud/dashboard.html
@@ -14,7 +14,8 @@
+{% with messages = get_flashed_messages(with_categories=true) %}
+ {% if messages %}
+
+ {% for category, message in messages %}
+
+ {{ category|capitalize }}: {{ message }}
+
+ {% endfor %}
+
+ {% endif %}
+{% endwith %}
+
+{% if devices %}
- {% if devices %}
-
- {% for device in devices %}
- -
- {{ device.device_name }}
- ({{ device.device_type }})
- {{ device.relative_path }}
-
- {% endfor %}
-
- {% else %}
-
No devices have been created yet.
- {% endif %}
+
+ {% for device in devices %}
+ -
+ {{ device.device_name }}
+ ({{ device.device_type }})
+ {{ device.relative_path }}
+
+ {% endfor %}
+
@@ -74,4 +84,41 @@
+{% else %}
+
+
+
+
+
+
+
+
+
+
+
+
+ After adding a device, the next phase is browser upload, file library browsing, and export tools.
+
+
+
+
+{% endif %}
{% endblock %}
diff --git a/app/templates/cloud/device_new.html b/app/templates/cloud/device_new.html
new file mode 100644
index 0000000..f9a721e
--- /dev/null
+++ b/app/templates/cloud/device_new.html
@@ -0,0 +1,89 @@
+{% extends "portal_base.html" %}
+
+{% block title %}Add Device - OTB Cloud{% endblock %}
+
+{% block portal_content %}
+
+
+{% with messages = get_flashed_messages(with_categories=true) %}
+ {% if messages %}
+
+ {% for category, message in messages %}
+
+ {{ category|capitalize }}: {{ message }}
+
+ {% endfor %}
+
+ {% endif %}
+{% endwith %}
+
+
+{% endblock %}