fix(migrations): reorder wizard category migration and apply pre-rebase formatting

Migration Changes:
- Renamed migration file from 20251004 to 20251005
- Updated down_revision from fd5a34530162 to 08a6c8fb44db
- Ensures migration runs after upstream notification events migration
- Fixes migration chain branching issue
- Removed incorrect merge migration (1e83c67d9785)

Migration chain is now:
fd5a34530162 (disabled attribute) → 08a6c8fb44db (notification events)
→ 20251005_add_category_to_wizard_step (wizard category)

Wizard Refactoring:
- Enhanced uniqueness constraint for WizardStep from (server_type, position)
  to (server_type, category, position)
- Ensures steps in different categories (pre_invite vs post_invite) can have
  the same position indexing without conflict
- Updated wizard state management documentation to explicitly list and
  describe data-* attributes used in steps.html template

Code Quality:
- Applied djlint formatting to 8 template files to minimize rebase conflicts
- Applied ruff format and ruff check --fix (no changes needed)
- Removed redundant requirement comments from spec-driven development
- Fixed case sensitivity in test assertion (POST vs post)
- Updated test references from 20251004 to 20251005 to match corrected
  migration revision ID
This commit is contained in:
engels74
2025-10-08 14:50:56 +02:00
parent fa50e60e3f
commit 0fb4767457
20 changed files with 369 additions and 431 deletions

View File

@@ -86,14 +86,12 @@ def _validate_secure_origin(origin, rp_id):
# Parse the origin
parsed_origin = urlparse(origin)
# Requirement 1: Must use HTTPS
if parsed_origin.scheme != "https":
raise ValueError(
"Passkeys require HTTPS. Current origin uses HTTP. "
"Please configure your application to use HTTPS or set WEBAUTHN_ORIGIN environment variable."
)
# Requirement 2: Must use a proper domain name (not IP address)
hostname = parsed_origin.hostname or rp_id
# Check if it's an IP address (IPv4 or IPv6) using Python's built-in validation

View File

@@ -55,13 +55,11 @@ def restrict_wizard():
if current_user.is_authenticated:
return None
# Requirement 13.2: Validate invite code on each request for pre-wizard routes
if request.endpoint and "pre_wizard" in request.endpoint:
invite_code = InviteCodeManager.get_invite_code()
if invite_code:
is_valid, invitation = InviteCodeManager.validate_invite_code(invite_code)
if not is_valid:
# Requirement 13.2: User-friendly error message for expired session
flash(
_(
"Your invitation has expired or is no longer valid. Please request a new invitation."
@@ -323,7 +321,6 @@ def _render(post, ctx: dict, server_type: str | None = None) -> str:
rendered_content, extensions=["fenced_code", "tables", "attr_list"]
)
except Exception as e:
# Requirement 13.6: Log error and return graceful fallback
current_app.logger.error(
f"Error rendering wizard step for {server_type}: {e}", exc_info=True
)
@@ -483,18 +480,14 @@ def pre_wizard(idx: int = 0):
For multi-server invitations, this redirects to the combo route with
category=pre_invite to show steps from all servers in sequence.
Requirements: 6.1-6.8, 13.1, 13.2, 13.5
Args:
idx: Current step index (default: 0)
Returns:
Rendered wizard template or redirect response
"""
# Requirement 13.1: Validate invite code from session
invite_code = InviteCodeManager.get_invite_code()
if not invite_code:
# Requirement 13.1: User-friendly error message for invalid invite code
flash(_("Invalid or expired invitation"), "error")
current_app.logger.warning("Pre-wizard accessed without invite code in session")
return redirect(url_for("public.index"))
@@ -502,7 +495,6 @@ def pre_wizard(idx: int = 0):
is_valid, invitation = InviteCodeManager.validate_invite_code(invite_code)
if not is_valid or not invitation:
# Requirement 13.1, 13.2: User-friendly error for invalid/expired invitation
flash(_("Invalid or expired invitation"), "error")
current_app.logger.warning(
f"Pre-wizard accessed with invalid invite code: {invite_code}"
@@ -518,7 +510,6 @@ def pre_wizard(idx: int = 0):
# Access the relationship - SQLAlchemy will load it
servers = list(invitation.servers) # type: ignore
except Exception as e:
# Requirement 13.3: Database query error handling with fallback
current_app.logger.error(
f"Error loading servers for invitation {invite_code}: {e}", exc_info=True
)
@@ -550,7 +541,6 @@ def pre_wizard(idx: int = 0):
# Handle case where no servers are configured
if not server_type:
# Requirement 13.6: Graceful degradation when no servers configured
flash(
_(
"No media servers are configured. Please contact the administrator to set up a media server."
@@ -565,11 +555,9 @@ def pre_wizard(idx: int = 0):
cfg = _settings()
steps = _steps(server_type, cfg, category="pre_invite")
except Exception as e:
# Requirement 13.3: Database query error handling
current_app.logger.error(
f"Error loading pre-wizard steps for {server_type}: {e}", exc_info=True
)
# Requirement 13.6: Graceful degradation - redirect to join page
flash(
_("Unable to load wizard steps. Proceeding to invitation acceptance."),
"warning",
@@ -638,8 +626,6 @@ def post_wizard(idx: int = 0):
For multi-server invitations, this redirects to the combo route with
category=post_invite to show steps from all servers in sequence.
Requirements: 8.1-8.8, 13.2, 13.3, 13.5, 13.6
Args:
idx: Current step index (default: 0)
@@ -649,7 +635,6 @@ def post_wizard(idx: int = 0):
# Check authentication (user must have accepted invitation)
# Allow access if user is authenticated OR has wizard_access session
if not current_user.is_authenticated and not session.get("wizard_access"):
# Requirement 13.2: User-friendly message for session expiration
flash(_("Please log in to continue"), "warning")
current_app.logger.warning("Post-wizard accessed without authentication")
return redirect(url_for("auth.login"))
@@ -663,7 +648,6 @@ def post_wizard(idx: int = 0):
try:
invitation = Invitation.query.filter_by(code=inv_code).first()
except Exception as e:
# Requirement 13.3: Database query error handling
current_app.logger.error(
f"Error querying invitation {inv_code}: {e}", exc_info=True
)
@@ -676,7 +660,6 @@ def post_wizard(idx: int = 0):
if hasattr(invitation, "servers") and invitation.servers:
servers = list(invitation.servers) # type: ignore
except Exception as e:
# Requirement 13.3: Database query error handling with fallback
current_app.logger.error(
f"Error loading servers for invitation {inv_code}: {e}",
exc_info=True,
@@ -715,13 +698,11 @@ def post_wizard(idx: int = 0):
if first_srv:
server_type = first_srv.server_type
except Exception as e:
# Requirement 13.3: Database query error handling
current_app.logger.error(
f"Error querying media servers: {e}", exc_info=True
)
if not server_type:
# Requirement 13.6: Graceful degradation when no servers configured
flash(
_(
"No media servers are configured. Please contact the administrator to set up a media server."
@@ -743,7 +724,6 @@ def post_wizard(idx: int = 0):
.all()
)
except Exception as e:
# Requirement 13.3: Database query error handling
current_app.logger.error(
f"Error querying post-wizard steps for {server_type}: {e}", exc_info=True
)
@@ -751,7 +731,6 @@ def post_wizard(idx: int = 0):
if not db_steps:
# No post-invite steps in database, redirect to completion page
# Requirement 8.2: Redirect to completion page when no post-invite steps exist
return redirect(url_for("wizard.complete"))
# Get post-invite steps (will use db_steps or fall back to legacy files)
@@ -759,7 +738,6 @@ def post_wizard(idx: int = 0):
cfg = _settings()
steps = _steps(server_type, cfg, category="post_invite")
except Exception as e:
# Requirement 13.3, 13.6: Database error with graceful degradation
current_app.logger.error(
f"Error loading post-wizard steps for {server_type}: {e}", exc_info=True
)
@@ -776,7 +754,6 @@ def post_wizard(idx: int = 0):
direction = request.values.get("dir", "")
if direction == "next" and (len(steps) == 1 or idx >= len(steps)):
# User completed all post-wizard steps
# Requirement 8.7: Redirect to completion page after completing all steps
return redirect(url_for("wizard.complete"))
# Render wizard using existing _serve_wizard logic
@@ -791,11 +768,8 @@ def complete():
- Success message confirming setup is complete
- Clear call-to-action to proceed to the application
- Automatic cleanup of all invitation-related session data
Requirements: 8.2, 8.7, 9.4
"""
# Clear all invitation-related session data
# Requirement 9.4: Clear all invitation-related data on completion
InviteCodeManager.clear_invite_data()
session.pop("wizard_access", None)
session.pop("wizard_server_order", None)
@@ -818,8 +792,6 @@ def start():
- Authenticated users → /post-wizard (they've already accepted an invitation)
- Users with invite code → /pre-wizard (they're in the invitation flow)
- Others → home page (no context available)
Requirements: 8.8, 12.4, 12.5
"""
run_all_importers()
@@ -879,8 +851,6 @@ def combo(idx: int):
from all servers in the invitation. It supports both pre-invite and post-invite
categories, determined by the 'category' query parameter.
Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 13.3, 13.5, 13.6
Args:
idx: Current step index
@@ -893,7 +863,6 @@ def combo(idx: int):
try:
cfg = _settings()
except Exception as e:
# Requirement 13.3: Database error handling
current_app.logger.error(
f"Error loading settings for combo wizard: {e}", exc_info=True
)
@@ -920,7 +889,6 @@ def combo(idx: int):
completion_label = _("Continue to Invite") if phase == "pre" else None
# Concatenate steps preserving order AND track which server each step belongs to
# Requirements 10.1, 10.2: Concatenate pre/post-invite steps for all servers
steps: list = []
step_server_mapping: list = [] # Track which server type each step belongs to
@@ -929,16 +897,13 @@ def combo(idx: int):
try:
server_steps = _steps(stype, cfg, category=category)
steps.extend(server_steps)
# Requirement 10.3: Maintain server type tracking for each step
step_server_mapping.extend([stype] * len(server_steps))
except Exception as e:
# Requirement 13.3, 13.6: Log error but continue with other servers
current_app.logger.error(
f"Error loading steps for {stype}/{category} in combo wizard: {e}",
exc_info=True,
)
# Requirements 10.5, 10.6: Handle case where no steps exist for any server
if not steps:
if category == "pre_invite":
# No pre-invite steps for any server - mark complete and redirect to join
@@ -947,10 +912,8 @@ def combo(idx: int):
return redirect(url_for("public.invite", code=invite_code))
return redirect(url_for("wizard.start"))
# No post-invite steps for any server - redirect to completion
# Requirement 10.6: Redirect to completion page when no post-invite steps exist
return redirect(url_for("wizard.complete"))
# Requirement 10.4: Ensure progress is maintained across server transitions
idx = max(0, min(idx, len(steps) - 1))
# Check if we're on the last step and moving forward
@@ -963,10 +926,8 @@ def combo(idx: int):
return redirect(url_for("public.invite", code=invite_code))
return redirect(url_for("wizard.start"))
# Completed all post-invite steps for all servers
# Requirement 8.7: Redirect to completion page after completing all steps
return redirect(url_for("wizard.complete"))
# Requirement 10.3: Get the server type for the current step
current_server_type = (
step_server_mapping[idx] if idx < len(step_server_mapping) else order[0]
)
@@ -1033,8 +994,6 @@ def bundle_view(idx: int):
Note: This function has custom logic for loading steps from bundles,
so it doesn't use _serve_wizard() directly. However, it maintains the same
rendering logic and template structure.
Requirements: 11.1-11.7, 13.3, 13.5, 13.6
"""
bundle_id = session.get("wizard_bundle_id")
if not bundle_id:
@@ -1046,7 +1005,6 @@ def bundle_view(idx: int):
try:
bundle = db.session.get(WizardBundle, bundle_id)
except Exception as e:
# Requirement 13.3: Database query error handling
current_app.logger.error(
f"Error loading wizard bundle {bundle_id}: {e}", exc_info=True
)
@@ -1066,7 +1024,6 @@ def bundle_view(idx: int):
)
steps_raw = [r.step for r in ordered]
except Exception as e:
# Requirement 13.3: Database query error handling
current_app.logger.error(
f"Error loading steps for bundle {bundle_id}: {e}", exc_info=True
)
@@ -1088,7 +1045,6 @@ def bundle_view(idx: int):
steps = [_RowAdapter(s) for s in steps_raw]
if not steps:
# Requirement 13.6: Graceful degradation for empty bundle
current_app.logger.warning(f"Wizard bundle {bundle_id} has no steps")
flash(_("This wizard bundle has no steps configured."), "warning")
return redirect(url_for("wizard.start"))
@@ -1112,7 +1068,6 @@ def bundle_view(idx: int):
settings = _settings()
html = _render(post, settings | {"_": _}, server_type=current_server_type)
except Exception as e:
# Requirement 13.6: Graceful degradation for rendering errors
current_app.logger.error(
f"Error rendering bundle step {idx} for bundle {bundle_id}: {e}",
exc_info=True,

View File

@@ -95,7 +95,6 @@ class InvitationFlowManager:
# If pre-invite steps exist and not completed, redirect to pre-wizard
if pre_steps_exist and not pre_wizard_complete:
# Requirement 7.2: Redirect to /pre-wizard if pre-invite steps exist and not completed
return InvitationResult(
status=ProcessingStatus.REDIRECT_REQUIRED,
message="Pre-wizard steps required",

View File

@@ -26,9 +26,8 @@
<div>
{{ form.category.label(class="block mb-1 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.category(class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:text-white") }}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('Choose when this step should be shown to users') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("Choose when this step should be shown to users") }}</p>
</div>
<div>
{{ form.preset_id.label(class="block mb-1 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.preset_id(class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:text-white", id="preset-select") }}

View File

@@ -21,9 +21,8 @@
<div>
{{ form.category.label(class="block mb-1 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.category(class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:text-white") }}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('Choose when this step should be shown to users') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("Choose when this step should be shown to users") }}</p>
</div>
<div>
{{ form.title.label(class="block mb-1 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.title(class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:text-white") }}

View File

@@ -26,9 +26,8 @@
<div>
{{ form.category.label(class="block mb-1 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.category(class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:text-white") }}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('Choose when this step should be shown to users') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("Choose when this step should be shown to users") }}</p>
</div>
<div>
{{ form.title.label(class="block mb-1 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.title(class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:text-white") }}

View File

@@ -71,27 +71,23 @@
fill="currentColor">
<path fill-rule="evenodd" d="M7 4a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 9a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 14a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0z" clip-rule="evenodd" />
</svg>
<div class="flex-1 flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ bs.step.title or _('Untitled') }}</span>
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ bs.step.title or _("Untitled") }}</span>
<!-- Category badge -->
{% if bs.step.category == 'pre_invite' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300">
{{ _('Pre-Invite') }}
{{ _("Pre-Invite") }}
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
{{ _('Post-Invite') }}
{{ _("Post-Invite") }}
</span>
{% endif %}
<!-- Server type badge -->
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
{{ bs.step.server_type | title }}
</span>
</div>
<span class="flex items-center gap-2">
<button hx-get="{{ url_for('wizard_admin.edit_step', step_id=bs.step.id) }}"
hx-target="#step-modal"

View File

@@ -166,27 +166,27 @@
</form>
</section>
<script>
// Enhanced file upload with drag and drop feedback
document.addEventListener('DOMContentLoaded', function() {
const fileInput = document.getElementById('{{ form.file.id }}');
const label = fileInput.closest('label');
// Enhanced file upload with drag and drop feedback
document.addEventListener('DOMContentLoaded', function() {
const fileInput = document.getElementById('{{ form.file.id }}');
const label = fileInput.closest('label');
// File selection feedback
fileInput.addEventListener('change', function() {
if (this.files && this.files[0]) {
const file = this.files[0];
const fileName = file.name;
const fileSize = (file.size / 1024).toFixed(1) + ' KB';
const isValidType = file.type === 'application/json' || fileName.endsWith('.json');
// File selection feedback
fileInput.addEventListener('change', function() {
if (this.files && this.files[0]) {
const file = this.files[0];
const fileName = file.name;
const fileSize = (file.size / 1024).toFixed(1) + ' KB';
const isValidType = file.type === 'application/json' || fileName.endsWith('.json');
// Update the label content
const content = label.querySelector('.flex.flex-col');
const iconColor = isValidType ? 'text-green-500' : 'text-red-500';
const iconPath = isValidType ?
'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' :
'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z';
// Update the label content
const content = label.querySelector('.flex.flex-col');
const iconColor = isValidType ? 'text-green-500' : 'text-red-500';
const iconPath = isValidType ?
'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' :
'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z';
content.innerHTML = `
content.innerHTML = `
<svg class="w-10 h-10 mb-4 ${iconColor}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="${iconPath}"></path>
</svg>
@@ -197,62 +197,62 @@
${!isValidType ? '<p class="text-xs text-red-600 dark:text-red-400 mt-1">Invalid file type</p>' : ''}
`;
// Update styling
label.classList.remove('border-gray-300', 'bg-gray-50', 'border-green-300', 'bg-green-50', 'border-red-300', 'bg-red-50');
if (isValidType) {
label.classList.add('border-green-300', 'bg-green-50', 'dark:bg-green-900/20');
} else {
label.classList.add('border-red-300', 'bg-red-50', 'dark:bg-red-900/20');
}
}
});
// Update styling
label.classList.remove('border-gray-300', 'bg-gray-50', 'border-green-300', 'bg-green-50', 'border-red-300', 'bg-red-50');
if (isValidType) {
label.classList.add('border-green-300', 'bg-green-50', 'dark:bg-green-900/20');
} else {
label.classList.add('border-red-300', 'bg-red-50', 'dark:bg-red-900/20');
}
}
});
// Drag and drop styling
['dragenter', 'dragover'].forEach(eventName => {
label.addEventListener(eventName, function(e) {
e.preventDefault();
this.classList.add('border-primary', 'bg-primary/5');
});
});
// Drag and drop styling
['dragenter', 'dragover'].forEach(eventName => {
label.addEventListener(eventName, function(e) {
e.preventDefault();
this.classList.add('border-primary', 'bg-primary/5');
});
});
['dragleave', 'drop'].forEach(eventName => {
label.addEventListener(eventName, function(e) {
e.preventDefault();
this.classList.remove('border-primary', 'bg-primary/5');
});
});
['dragleave', 'drop'].forEach(eventName => {
label.addEventListener(eventName, function(e) {
e.preventDefault();
this.classList.remove('border-primary', 'bg-primary/5');
});
});
label.addEventListener('drop', function(e) {
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
fileInput.files = e.dataTransfer.files;
fileInput.dispatchEvent(new Event('change'));
}
});
label.addEventListener('drop', function(e) {
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
fileInput.files = e.dataTransfer.files;
fileInput.dispatchEvent(new Event('change'));
}
});
// Form validation
const form = document.querySelector('form');
form.addEventListener('submit', function(e) {
if (!fileInput.files || !fileInput.files[0]) {
e.preventDefault();
alert('{{ _("Please select a JSON file to import.") }}');
return false;
}
// Form validation
const form = document.querySelector('form');
form.addEventListener('submit', function(e) {
if (!fileInput.files || !fileInput.files[0]) {
e.preventDefault();
alert('{{ _("Please select a JSON file to import.") }}');
return false;
}
const file = fileInput.files[0];
if (!file.name.endsWith('.json') && file.type !== 'application/json') {
e.preventDefault();
alert('{{ _("Please select a valid JSON file.") }}');
return false;
}
const file = fileInput.files[0];
if (!file.name.endsWith('.json') && file.type !== 'application/json') {
e.preventDefault();
alert('{{ _("Please select a valid JSON file.") }}');
return false;
}
// Show confirmation for replace existing
const replaceCheckbox = document.getElementById('{{ form.replace_existing.id }}');
if (replaceCheckbox && replaceCheckbox.checked) {
if (!confirm('{{ _("This will permanently replace all existing wizard configuration (steps and bundles). Are you sure you want to continue?") }}')) {
e.preventDefault();
return false;
}
}
});
});
// Show confirmation for replace existing
const replaceCheckbox = document.getElementById('{{ form.replace_existing.id }}');
if (replaceCheckbox && replaceCheckbox.checked) {
if (!confirm('{{ _("This will permanently replace all existing wizard configuration (steps and bundles). Are you sure you want to continue?") }}')) {
e.preventDefault();
return false;
}
}
});
});
</script>

View File

@@ -7,11 +7,7 @@
<section class="space-y-6">
{% set is_default = True %}
{% include 'settings/wizard/_sub_nav.html' with context %}
{% if not grouped %}
<p class="text-gray-600 dark:text-gray-300">{{ _("No steps found.") }}</p>
{% endif %}
{% if not grouped %}<p class="text-gray-600 dark:text-gray-300">{{ _("No steps found.") }}</p>{% endif %}
{% for server, categories in grouped.items() %}
<div class="border rounded-md p-4 dark:border-gray-700">
{# Server header with actions #}
@@ -62,102 +58,162 @@
</a>
</div>
</div>
{# Two-column layout for pre-invite and post-invite steps #}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{# Left column: Pre-Invite Steps #}
<div class="wizard-category-section border rounded-lg p-4 bg-gray-50 dark:bg-gray-900 dark:border-gray-600">
<h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-2">{{ _("Before Invite Acceptance") }}</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">{{ _("Steps shown to users before they accept the invitation") }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ _("Steps shown to users before they accept the invitation") }}
</p>
{% set pre_steps = categories.get('pre_invite', []) %}
{# Always render the wizard-steps container, even when empty, so it can be a Sortable.js drop target #}
<ol class="wizard-steps bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 {% if not pre_steps %}min-h-[120px] flex items-center justify-center{% endif %}"
<ol class="wizard-steps bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700
{% if not pre_steps %}min-h-[120px] flex items-center justify-center{% endif %}"
data-server="{{ server }}"
data-category="pre_invite"
data-reorder-url="{{ url_for('wizard_admin.reorder_steps') }}">
{% if pre_steps %}
{% for step in pre_steps %}
<li class="flex items-center justify-between px-4 py-3" data-id="{{ step.id }}">
<svg class="drag w-4 h-4 cursor-grab text-gray-400 mr-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7 4a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 9a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 14a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0z" clip-rule="evenodd" /></svg>
<li class="flex items-center justify-between px-4 py-3"
data-id="{{ step.id }}">
<svg class="drag w-4 h-4 cursor-grab text-gray-400 mr-3"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd" d="M7 4a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 9a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 14a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0z" clip-rule="evenodd" />
</svg>
<span class="flex-1 text-sm font-medium text-gray-900 dark:text-white">{{ step.title|render_jinja if step.title else _("Untitled") }}</span>
<span class="flex items-center gap-2">
<button hx-get="{{ url_for('wizard_admin.edit_step', step_id=step.id) }}"
hx-target="#step-modal" hx-trigger="click"
hx-target="#step-modal"
hx-trigger="click"
class="inline-flex items-center justify-center p-2 text-gray-800 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:text-white dark:hover:bg-gray-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
<svg class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z">
</path>
</svg>
</button>
<form method="post" action="{{ url_for('wizard_admin.delete_step', step_id=step.id) }}" class="inline"
<form method="post"
action="{{ url_for('wizard_admin.delete_step', step_id=step.id) }}"
class="inline"
hx-post="{{ url_for('wizard_admin.delete_step', step_id=step.id) }}"
hx-target="#tab-body" hx-swap="innerHTML"
hx-target="#tab-body"
hx-swap="innerHTML"
hx-confirm="{{ _('Are you sure you want to delete this step?') }}">
<button type="submit" class="inline-flex items-center justify-center p-2 text-red-500 rounded-lg hover:text-white hover:bg-red-500 dark:text-red-400 dark:hover:text-white dark:hover:bg-red-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"></path>
<button type="submit"
class="inline-flex items-center justify-center p-2 text-red-500 rounded-lg hover:text-white hover:bg-red-500 dark:text-red-400 dark:hover:text-white dark:hover:bg-red-600">
<svg class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd">
</path>
</svg>
</button>
</form>
</span>
<svg class="drag w-4 h-4 cursor-grab text-gray-400 ml-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7 4a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 9a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 14a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0z" clip-rule="evenodd" /></svg>
<svg class="drag w-4 h-4 cursor-grab text-gray-400 ml-3"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd" d="M7 4a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 9a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 14a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0z" clip-rule="evenodd" />
</svg>
</li>
{% endfor %}
{% else %}
{# Empty state message displayed inside the sortable container #}
<li class="empty-state-item flex flex-col items-center justify-center text-center py-8 text-gray-400 dark:text-gray-500 w-full list-none">
<svg class="w-12 h-12 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
<svg class="w-12 h-12 mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
</path>
</svg>
<p class="text-sm">{{ _("No pre-invite steps configured") }}</p>
</li>
{% endif %}
</ol>
</div>
{# Right column: Post-Invite Steps #}
<div class="wizard-category-section border rounded-lg p-4 bg-gray-50 dark:bg-gray-900 dark:border-gray-600">
<h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-2">{{ _("After Invite Acceptance") }}</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">{{ _("Steps shown to users after they accept the invitation") }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ _("Steps shown to users after they accept the invitation") }}
</p>
{% set post_steps = categories.get('post_invite', []) %}
{# Always render the wizard-steps container, even when empty, so it can be a Sortable.js drop target #}
<ol class="wizard-steps bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 {% if not post_steps %}min-h-[120px] flex items-center justify-center{% endif %}"
<ol class="wizard-steps bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700
{% if not post_steps %}min-h-[120px] flex items-center justify-center{% endif %}"
data-server="{{ server }}"
data-category="post_invite"
data-reorder-url="{{ url_for('wizard_admin.reorder_steps') }}">
{% if post_steps %}
{% for step in post_steps %}
<li class="flex items-center justify-between px-4 py-3" data-id="{{ step.id }}">
<svg class="drag w-4 h-4 cursor-grab text-gray-400 mr-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7 4a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 9a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 14a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0z" clip-rule="evenodd" /></svg>
<li class="flex items-center justify-between px-4 py-3"
data-id="{{ step.id }}">
<svg class="drag w-4 h-4 cursor-grab text-gray-400 mr-3"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd" d="M7 4a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 9a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 14a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0z" clip-rule="evenodd" />
</svg>
<span class="flex-1 text-sm font-medium text-gray-900 dark:text-white">{{ step.title|render_jinja if step.title else _("Untitled") }}</span>
<span class="flex items-center gap-2">
<button hx-get="{{ url_for('wizard_admin.edit_step', step_id=step.id) }}"
hx-target="#step-modal" hx-trigger="click"
hx-target="#step-modal"
hx-trigger="click"
class="inline-flex items-center justify-center p-2 text-gray-800 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:text-white dark:hover:bg-gray-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
<svg class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z">
</path>
</svg>
</button>
<form method="post" action="{{ url_for('wizard_admin.delete_step', step_id=step.id) }}" class="inline"
<form method="post"
action="{{ url_for('wizard_admin.delete_step', step_id=step.id) }}"
class="inline"
hx-post="{{ url_for('wizard_admin.delete_step', step_id=step.id) }}"
hx-target="#tab-body" hx-swap="innerHTML"
hx-target="#tab-body"
hx-swap="innerHTML"
hx-confirm="{{ _('Are you sure you want to delete this step?') }}">
<button type="submit" class="inline-flex items-center justify-center p-2 text-red-500 rounded-lg hover:text-white hover:bg-red-500 dark:text-red-400 dark:hover:text-white dark:hover:bg-red-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"></path>
<button type="submit"
class="inline-flex items-center justify-center p-2 text-red-500 rounded-lg hover:text-white hover:bg-red-500 dark:text-red-400 dark:hover:text-white dark:hover:bg-red-600">
<svg class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd">
</path>
</svg>
</button>
</form>
</span>
<svg class="drag w-4 h-4 cursor-grab text-gray-400 ml-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7 4a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 9a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 14a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0z" clip-rule="evenodd" /></svg>
<svg class="drag w-4 h-4 cursor-grab text-gray-400 ml-3"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd" d="M7 4a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 9a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0zM7 14a1 1 0 112 0 1 1 0 01-2 0zm4 0a1 1 0 112 0 1 1 0 01-2 0z" clip-rule="evenodd" />
</svg>
</li>
{% endfor %}
{% else %}
{# Empty state message displayed inside the sortable container #}
<li class="empty-state-item flex flex-col items-center justify-center text-center py-8 text-gray-400 dark:text-gray-500 w-full list-none">
<svg class="w-12 h-12 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
<svg class="w-12 h-12 mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
</path>
</svg>
<p class="text-sm">{{ _("No post-invite steps configured") }}</p>
</li>

View File

@@ -15,29 +15,64 @@
data-next-label="{{ _('Next') }}"
data-phase-label-pre="{{ _('Before Invitation') }}"
data-phase-label-post="{{ _('After Invitation') }}">
{% set current_step_phase = step_phase or '' %}
{% set completion_href = completion_url or '' %}
{% set current_step_phase = step_phase or '' %}
{% set completion_href = completion_url or '' %}
{% set completion_label_text = completion_label or _('Continue to Invite') %}
{% set default_next_label = _('Next') %}
{% set is_pre_phase = phase == 'pre' %}
{% set is_last_step = idx == max_idx %}
{% set is_pre_final = is_pre_phase and is_last_step and completion_href %}
<!-- FLOATING PROGRESS BAR (mobile - floating at top) -->
{% if max_idx > 0 %}
<div id="wizard-progress-mobile" class="wizard-progress-mobile md:hidden">
<div class="wizard-glass-container">
<div id="wizard-progress-mobile" class="wizard-progress-mobile md:hidden">
<div class="wizard-glass-container">
<!-- Phase Indicator -->
<div id="wizard-phase-container-mobile"
class="flex justify-center mb-2"
{% if not current_step_phase %}style="display:none"{% endif %}>
<span id="wizard-phase-label-mobile"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{% if current_step_phase == 'pre' %}
bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
{% elif current_step_phase == 'post' %}
bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% endif %}"
data-phase-label="mobile">
{% if current_step_phase == 'pre' %}
{{ _("Before Invitation") }}
{% elif current_step_phase == 'post' %}
{{ _("After Invitation") }}
{% endif %}
</span>
</div>
<div class="flex justify-between items-center mb-2 text-xs text-gray-600 dark:text-gray-400">
<span>{{ _("Step") }} {{ idx + 1 }} {{ _("of") }} {{ max_idx + 1 }}</span>
<span class="font-semibold">{{ ((idx + 1) / (max_idx + 1) * 100) | round | int }}%</span>
</div>
<div class="w-full bg-gray-200/50 dark:bg-gray-700/50 rounded-full h-1.5 overflow-hidden">
<div id="progress-bar-mobile"
class="h-1.5 rounded-full transition-all duration-500 ease-out"
style="width: {{ ((idx + 1) / (max_idx + 1) * 100) }}%;
background: linear-gradient(90deg, #fe4155 0%, #fe4155 100%);
box-shadow: 0 0 8px rgba(254, 65, 85, 0.3)"></div>
</div>
</div>
</div>
<!-- DESKTOP PROGRESS BAR (centered) -->
<div id="wizard-progress-desktop"
class="hidden md:block w-full max-w-md mb-6">
<!-- Phase Indicator -->
<div id="wizard-phase-container-mobile" class="flex justify-center mb-2" {% if not current_step_phase %}style="display:none"{% endif %}>
<span id="wizard-phase-label-mobile"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
<div id="wizard-phase-container-desktop"
class="flex justify-center mb-3"
{% if not current_step_phase %}style="display:none"{% endif %}>
<span id="wizard-phase-label-desktop"
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
{% if current_step_phase == 'pre' %}
bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
{% elif current_step_phase == 'post' %}
bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% endif %}"
data-phase-label="mobile">
data-phase-label="desktop">
{% if current_step_phase == 'pre' %}
{{ _("Before Invitation") }}
{% elif current_step_phase == 'post' %}
@@ -45,51 +80,18 @@
{% endif %}
</span>
</div>
<div class="flex justify-between items-center mb-2 text-xs text-gray-600 dark:text-gray-400">
<div class="flex justify-between items-center mb-2 text-sm text-gray-600 dark:text-gray-400">
<span>{{ _("Step") }} {{ idx + 1 }} {{ _("of") }} {{ max_idx + 1 }}</span>
<span class="font-semibold">{{ ((idx + 1) / (max_idx + 1) * 100) | round | int }}%</span>
<span>{{ ((idx + 1) / (max_idx + 1) * 100) | round | int }}%</span>
</div>
<div class="w-full bg-gray-200/50 dark:bg-gray-700/50 rounded-full h-1.5 overflow-hidden">
<div id="progress-bar-mobile"
class="h-1.5 rounded-full transition-all duration-500 ease-out"
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700 overflow-hidden">
<div id="progress-bar-desktop"
class="h-2.5 rounded-full transition-all duration-500 ease-out"
style="width: {{ ((idx + 1) / (max_idx + 1) * 100) }}%;
background: linear-gradient(90deg, #fe4155 0%, #fe4155 100%);
box-shadow: 0 0 8px rgba(254, 65, 85, 0.3)"></div>
box-shadow: 0 0 10px rgba(254, 65, 85, 0.3)"></div>
</div>
</div>
</div>
<!-- DESKTOP PROGRESS BAR (centered) -->
<div id="wizard-progress-desktop" class="hidden md:block w-full max-w-md mb-6">
<!-- Phase Indicator -->
<div id="wizard-phase-container-desktop" class="flex justify-center mb-3" {% if not current_step_phase %}style="display:none"{% endif %}>
<span id="wizard-phase-label-desktop"
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
{% if current_step_phase == 'pre' %}
bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
{% elif current_step_phase == 'post' %}
bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% endif %}"
data-phase-label="desktop">
{% if current_step_phase == 'pre' %}
{{ _("Before Invitation") }}
{% elif current_step_phase == 'post' %}
{{ _("After Invitation") }}
{% endif %}
</span>
</div>
<div class="flex justify-between items-center mb-2 text-sm text-gray-600 dark:text-gray-400">
<span>{{ _("Step") }} {{ idx + 1 }} {{ _("of") }} {{ max_idx + 1 }}</span>
<span>{{ ((idx + 1) / (max_idx + 1) * 100) | round | int }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700 overflow-hidden">
<div id="progress-bar-desktop"
class="h-2.5 rounded-full transition-all duration-500 ease-out"
style="width: {{ ((idx + 1) / (max_idx + 1) * 100) }}%;
background: linear-gradient(90deg, #fe4155 0%, #fe4155 100%);
box-shadow: 0 0 10px rgba(254, 65, 85, 0.3)"></div>
</div>
</div>
{% endif %}
<!-- CARD (full height on mobile, centered on desktop) -->
<div class="htmx-indicator absolute inset-0 bg-white/80 dark:bg-gray-900/80 flex items-center justify-center z-50">
@@ -108,110 +110,100 @@
{% include "wizard/_content.html" %}
<!-- FLOATING NAV BUTTONS (mobile - fixed at bottom) -->
<div class="wizard-nav-mobile md:hidden">
<a id="wizard-prev-btn"
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx-1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx-1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx-1) }}"
{% endif %}
hx-vals='{"dir":"prev"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
class="wizard-btn-mobile wizard-btn-prev"
{% if idx == 0 %}style="display:none"{% endif %}
aria-label="{{ _('Previous') }}">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<a id="wizard-next-btn"
{% if is_pre_final %}
href="{{ completion_href }}"
hx-get="{{ completion_href }}"
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
data-final-step="1"
{% else %}
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx+1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx+1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx+1) }}"
{% endif %}
hx-vals='{"dir":"next"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
{% endif %}
class="wizard-btn-mobile wizard-btn-next"
{% if not is_pre_final and idx == max_idx %}style="display:none"{% endif %}
data-default-aria-label="{{ default_next_label }}"
aria-label="{{ completion_label_text if is_pre_final else default_next_label }}"
{% if require_interaction %} aria-disabled="true" data-disabled="1" title="{{ _('Click the link in this step to be able to continue') }}"{% endif %}>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/>
</svg>
</a>
<a id="wizard-prev-btn"
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx-1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx-1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx-1) }}"
{% endif %}
hx-vals='{"dir":"prev"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
class="wizard-btn-mobile wizard-btn-prev"
{% if idx == 0 %}style="display:none"{% endif %}
aria-label="{{ _('Previous') }}">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 19l-7-7 7-7" />
</svg>
</a>
<a id="wizard-next-btn"
{% if is_pre_final %}
href="{{ completion_href }}" hx-get="{{ completion_href }}" hx-target="#wizard-content" hx-swap="outerHTML swap:0s" hx-indicator=".htmx-indicator" data-final-step="1"
{% else %}
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx+1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx+1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx+1) }}"
{% endif %}
hx-vals='{"dir":"next"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
{% endif %}
class="wizard-btn-mobile wizard-btn-next"
{% if not is_pre_final and idx == max_idx %}style="display:none"{% endif %}
data-default-aria-label="{{ default_next_label }}"
aria-label="{{ completion_label_text if is_pre_final else default_next_label }}"
{% if require_interaction %}
aria-disabled="true" data-disabled="1" title="{{ _('Click the link in this step to be able to continue') }}"
{% endif %}>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
<!-- DESKTOP NAV BUTTONS (centered below card) -->
<div class="hidden md:flex justify-center space-x-4 mt-6">
<a id="wizard-prev-btn-desktop"
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx-1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx-1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx-1) }}"
{% endif %}
hx-vals='{"dir":"prev"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
class="btn-nav"
{% if idx == 0 %}style="display:none"{% endif %}>
{{ _("Previous") }}
</a>
<a id="wizard-next-btn-desktop"
{% if is_pre_final %}
href="{{ completion_href }}"
hx-get="{{ completion_href }}"
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
data-final-step="1"
{% else %}
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx+1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx+1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx+1) }}"
{% endif %}
hx-vals='{"dir":"next"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
{% endif %}
class="btn-nav"
{% if not is_pre_final and idx == max_idx %}style="display:none"{% endif %}
data-default-html="{{ _("Next") }} "
data-final-html="{{ completion_label_text }}"
data-default-aria-label="{{ default_next_label }}"
aria-label="{{ completion_label_text if is_pre_final else default_next_label }}"
{% if require_interaction %} aria-disabled="true" data-disabled="1" title="{{ _('Click the link in this step to be able to continue') }}"{% endif %}>
{% if is_pre_final %}
{{ completion_label_text }}
{% else %}
{{ _("Next") }}
{% endif %}
</a>
<a id="wizard-prev-btn-desktop"
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx-1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx-1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx-1) }}"
{% endif %}
hx-vals='{"dir":"prev"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
class="btn-nav"
{% if idx == 0 %}style="display:none"{% endif %}> {{ _("Previous") }}</a>
<a id="wizard-next-btn-desktop"
{% if is_pre_final %}
href="{{ completion_href }}" hx-get="{{ completion_href }}" hx-target="#wizard-content" hx-swap="outerHTML swap:0s" hx-indicator=".htmx-indicator" data-final-step="1"
{% else %}
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx+1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx+1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx+1) }}"
{% endif %}
hx-vals='{"dir":"next"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
{% endif %}
class="btn-nav"
{% if not is_pre_final and idx == max_idx %}style="display:none"{% endif %}
data-default-html="{{ _('Next') }} "
data-final-html="{{ completion_label_text }}"
data-default-aria-label="{{ default_next_label }}"
aria-label="{{ completion_label_text if is_pre_final else default_next_label }}"
{% if require_interaction %}
aria-disabled="true" data-disabled="1" title="{{ _('Click the link in this step to be able to continue') }}"
{% endif %}>
{% if is_pre_final %}
{{ completion_label_text }}
{% else %}
{{ _("Next") }}
{% endif %}
</a>
</div>
</div>
<script>
@@ -235,7 +227,14 @@
* 3. State Management:
* - Server sends headers: X-Wizard-Idx, X-Require-Interaction, X-Wizard-Step-Phase
* - Client updates: Progress bars, button URLs, button visibility, phase badges
* - Wrapper stores: data-current-idx, data-max-idx, data-server-type, data-phase, data-step-phase
* - Wrapper stores:
* - data-current-idx: Current wizard step index (int, 0-based)
* - data-max-idx: Maximum wizard step index (int)
* - data-server-type: Type of server being configured (string, e.g., "plex", "jellyfin")
* - data-phase: Current wizard phase (string, "pre" or "post")
* - data-step-phase: Current step phase (string, "pre" or "post" for pre-invite/post-invite)
* These attributes are updated by the server and client-side JS to synchronize UI state,
* drive progress bar, navigation button logic, and ensure correct HTMX swaps.
*
* 4. Mobile Features:
* - Fixed progress bar at top (no scroll)
@@ -376,11 +375,11 @@
const preLabel = this.wrapper.dataset.phaseLabelPre || 'Before Invitation';
const postLabel = this.wrapper.dataset.phaseLabelPost || 'After Invitation';
const phaseText = phase === 'pre'
? preLabel
: phase === 'post'
? postLabel
: '';
const phaseText = phase === 'pre' ?
preLabel :
phase === 'post' ?
postLabel :
'';
const PRE_CLASSES = ['bg-blue-100', 'text-blue-800', 'dark:bg-blue-900', 'dark:text-blue-200'];
const POST_CLASSES = ['bg-green-100', 'text-green-800', 'dark:bg-green-900', 'dark:text-green-200'];
@@ -454,12 +453,19 @@
const completionLabel = this.getCompletionLabel();
const defaultNextLabel = this.getDefaultNextLabel();
const isFinalPreStep = phase === 'pre' && idx === maxIdx && completionUrl;
const buttonConfigs = [
{ id: 'wizard-prev-btn', isPrev: true },
{ id: 'wizard-prev-btn-desktop', isPrev: true },
{ id: 'wizard-next-btn', isPrev: false },
{ id: 'wizard-next-btn-desktop', isPrev: false }
];
const buttonConfigs = [{
id: 'wizard-prev-btn',
isPrev: true
}, {
id: 'wizard-prev-btn-desktop',
isPrev: true
}, {
id: 'wizard-next-btn',
isPrev: false
}, {
id: 'wizard-next-btn-desktop',
isPrev: false
}];
buttonConfigs.forEach(({
id,

View File

@@ -1,21 +0,0 @@
"""merge category and notification_events migrations
Revision ID: 1e83c67d9785
Revises: 20251004_add_category_to_wizard_step, 08a6c8fb44db
Create Date: 2025-10-04 16:48:51.198273
"""
# revision identifiers, used by Alembic.
revision = "1e83c67d9785"
down_revision = ("20251004_add_category_to_wizard_step", "08a6c8fb44db")
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@@ -1,8 +1,8 @@
"""Add category field to wizard_step table
Revision ID: 20251004_add_category_to_wizard_step
Revises: fd5a34530162
Create Date: 2025-10-04 12:44:00.000000
Revision ID: 20251005_add_category_to_wizard_step
Revises: 08a6c8fb44db
Create Date: 2025-10-05 14:00:00.000000
"""
@@ -10,8 +10,8 @@ import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "20251004_add_category_to_wizard_step"
down_revision = "fd5a34530162"
revision = "20251005_add_category_to_wizard_step"
down_revision = "08a6c8fb44db"
branch_labels = None
depends_on = None

View File

@@ -542,8 +542,8 @@ class TestInvitationUIComponents:
expect(page.locator("label").filter(has_text="Password").first).to_be_visible()
expect(page.locator("label").filter(has_text="Email")).to_be_visible()
# Check form has proper structure (HTML attributes are case-insensitive)
expect(page.locator("form")).to_have_attribute("method", "post")
# Check form uses POST method
expect(page.locator("form")).to_have_attribute("method", "POST")
# Test keyboard navigation
page.keyboard.press("Tab") # Should focus first input

View File

@@ -4,8 +4,6 @@ Final integration tests for wizard pre/post-invite steps refactor.
This test suite provides comprehensive end-to-end testing of the complete
invitation flow with pre and post-wizard steps across multiple service types,
multi-server invitations, wizard bundles, and error scenarios.
Requirements: 15.2, 15.4
"""
from datetime import UTC, datetime, timedelta

View File

@@ -7,8 +7,6 @@ Tests verify the complete user journey through the invitation system:
- Flow with no pre-invite steps
- Flow with no post-invite steps
- Flow with both pre and post-invite steps
Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 15.2
"""
import pytest
@@ -86,10 +84,7 @@ class TestCompleteInvitationFlow:
def test_complete_flow_with_pre_and_post_steps(
self, client, app, setup_invitation_with_steps
):
"""Test complete flow: invite link → pre-wizard → join → post-wizard.
Requirements: 7.1, 7.2, 7.3, 7.4, 15.2
"""
"""Test complete flow: invite link → pre-wizard → join → post-wizard."""
with app.app_context():
# Step 1: Access invitation link /j/<code>
response = client.get("/j/FLOW123", follow_redirects=False)
@@ -124,10 +119,7 @@ class TestCompleteInvitationFlow:
def test_bypass_prevention_cannot_skip_pre_wizard(
self, client, app, setup_invitation_with_steps
):
"""Test that users cannot bypass pre-wizard steps.
Requirements: 7.1, 7.2, 7.6, 15.2
"""
"""Test that users cannot bypass pre-wizard steps."""
with app.app_context():
# Try to access join page directly without completing pre-wizard
response = client.get("/j/FLOW123", follow_redirects=False)
@@ -208,10 +200,7 @@ class TestFlowWithoutPreInviteSteps:
def test_flow_without_pre_invite_steps(
self, client, app, setup_invitation_without_pre_steps
):
"""Test flow when no pre-invite steps exist - should go directly to join.
Requirements: 7.5, 15.2
"""
"""Test flow when no pre-invite steps exist - should go directly to join."""
with app.app_context():
# Access invitation link
response = client.get("/j/NOPREFLOW", follow_redirects=False)
@@ -281,10 +270,7 @@ class TestFlowWithoutPostInviteSteps:
def test_flow_without_post_invite_steps(
self, client, app, setup_invitation_without_post_steps
):
"""Test flow when no post-invite steps exist.
Requirements: 15.2
"""
"""Test flow when no post-invite steps exist."""
with app.app_context():
# Access invitation link - should redirect to pre-wizard
response = client.get("/j/NOPOSTFLOW", follow_redirects=False)

View File

@@ -368,7 +368,7 @@ def test_wizard_step_category_migration_upgrade(migration_app, temp_db):
conn.commit()
# Now run the category migration
upgrade(revision="20251004_add_category_to_wizard_step")
upgrade(revision="20251005_add_category_to_wizard_step")
# Verify the migration succeeded
with engine.connect() as conn:
@@ -412,7 +412,7 @@ def test_wizard_step_category_migration_downgrade(migration_app, temp_db):
"""Test that the category field migration can be downgraded without data loss."""
with migration_app.app_context():
# Run migrations up to and including the category migration
upgrade(revision="20251004_add_category_to_wizard_step")
upgrade(revision="20251005_add_category_to_wizard_step")
# Insert test data with both pre_invite and post_invite steps
engine = create_engine(temp_db)
@@ -480,7 +480,7 @@ def test_wizard_step_category_unique_constraint(migration_app, temp_db):
"""Test that the unique constraint works correctly with category field."""
with migration_app.app_context():
# Run migrations up to and including the category migration
upgrade(revision="20251004_add_category_to_wizard_step")
upgrade(revision="20251005_add_category_to_wizard_step")
engine = create_engine(temp_db)
with engine.connect() as conn:

View File

@@ -56,10 +56,7 @@ class TestPostWizardAuthentication:
"""Test authentication requirements for post-wizard endpoint."""
def test_redirect_to_login_when_not_authenticated(self, app, client):
"""Test that unauthenticated users without wizard_access are redirected.
Requirement: 8.5
"""
"""Test that unauthenticated users without wizard_access are redirected."""
# Try to access post-wizard without authentication or wizard_access
response = client.get("/wizard/post-wizard", follow_redirects=False)
@@ -68,10 +65,7 @@ class TestPostWizardAuthentication:
assert response.location in ["/login", "/"]
def test_allow_access_with_wizard_access_session(self, app, client, session):
"""Test that users with wizard_access session can access post-wizard.
Requirement: 8.5
"""
"""Test that users with wizard_access session can access post-wizard."""
# Create server
server = MediaServer(
name="Test Jellyfin",
@@ -112,10 +106,7 @@ class TestPostWizardStepFiltering:
"""Test that post-wizard only shows post_invite category steps."""
def test_only_display_post_invite_steps(self, app, client, session):
"""Test that only post_invite category steps are displayed.
Requirement: 8.3
"""
"""Test that only post_invite category steps are displayed."""
# Create server
server = MediaServer(
name="Test Jellyfin",
@@ -169,10 +160,7 @@ class TestPostWizardCompletion:
def test_redirect_to_completion_when_no_post_invite_steps(
self, app, client, session
):
"""Test redirect to completion page when no post-invite steps exist.
Requirement: 8.2
"""
"""Test redirect to completion page when no post-invite steps exist."""
# Delete all wizard steps to ensure none exist
session.query(WizardStep).delete()
session.commit()
@@ -207,10 +195,7 @@ class TestPostWizardCompletion:
assert response.location == "/wizard/complete"
def test_clear_invite_data_on_completion(self, app, client, session):
"""Test that invite data is cleared when no post-invite steps exist.
Requirement: 8.7, 9.4
"""
"""Test that invite data is cleared when no post-invite steps exist."""
# Delete all wizard steps to ensure none exist
session.query(WizardStep).delete()
session.commit()
@@ -247,10 +232,7 @@ class TestPostWizardCompletion:
assert "wizard_access" not in sess
def test_clear_invite_data_after_completing_all_steps(self, app, client, session):
"""Test that invite data is cleared after completing all post-wizard steps.
Requirement: 8.7, 9.4
"""
"""Test that invite data is cleared after completing all post-wizard steps."""
# Delete all wizard steps first
session.query(WizardStep).delete()
session.commit()

View File

@@ -7,8 +7,6 @@ Tests cover:
- Session expiration handling (Requirement 13.2, 13.5)
- Database query failure fallbacks (Requirement 13.3)
- Graceful degradation for missing steps (Requirement 13.6)
Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 13.7, 15.1
"""
import datetime

View File

@@ -5,8 +5,6 @@ The start() function should intelligently redirect users based on their context:
- Authenticated users → /post-wizard
- Users with invite code → /pre-wizard
- Others → home page
Requirements: 8.8, 12.4, 12.5, 15.2
"""
import pytest
@@ -54,10 +52,7 @@ class TestWizardStartRedirect:
assert "/wizard/" in rules
def test_authenticated_user_redirects_to_post_wizard(self, app, client, session):
"""Test that authenticated users are redirected to /post-wizard.
Requirement: 8.8, 12.5
"""
"""Test that authenticated users are redirected to /post-wizard."""
# Create admin user
admin = AdminAccount(username="testadmin")
admin.set_password("testpass123")
@@ -93,8 +88,6 @@ class TestWizardStartRedirect:
This simulates a user who has just accepted an invitation and has
the wizard_access session flag set.
Requirement: 8.8, 12.5
"""
# Create media server and invitation
server = MediaServer(
@@ -144,8 +137,6 @@ class TestWizardStartRedirect:
Users who access /wizard directly without being authenticated
or having an invite code should be redirected to the home page.
Requirement: 8.8, 12.4
"""
# Create media server (needed for the app to function)
server = MediaServer(
@@ -169,8 +160,6 @@ class TestWizardStartRedirect:
The /wizard endpoint should continue to work for existing links
and bookmarks, redirecting appropriately based on user context.
Requirement: 12.5
"""
# Create admin user and media server
admin = AdminAccount(username="testadmin")
@@ -195,10 +184,7 @@ class TestWizardStartRedirect:
assert "/wizard/post-wizard" in response.location
def test_backward_compatibility_without_context(self, app, client, session):
"""Test backward compatibility redirects to home when no context.
Requirement: 12.5
"""
"""Test backward compatibility redirects to home when no context."""
# Create media server (needed for the app to function)
server = MediaServer(
name="Test Plex",

View File

@@ -23,6 +23,7 @@ def test_create_wizard_step(session):
"""Basic insertion & to_dict serialization work."""
step = WizardStep(
server_type="plex",
category="post_invite", # Explicitly set category
position=0,
title="Welcome",
markdown="# Welcome\nSome intro text",
@@ -39,13 +40,14 @@ def test_create_wizard_step(session):
def test_unique_server_position_constraint(session):
"""(server_type, position) must be unique."""
a = WizardStep(server_type="plex", position=1, markdown="a")
b = WizardStep(server_type="plex", position=1, markdown="b")
"""(server_type, category, position) must be unique."""
# Both steps must have same category to test the unique constraint
a = WizardStep(server_type="plex", category="post_invite", position=1, markdown="a")
b = WizardStep(server_type="plex", category="post_invite", position=1, markdown="b")
session.add(a)
session.commit()
session.add(b)
with pytest.raises(IntegrityError):
session.commit() # duplicate position for same server_type
session.commit() # duplicate (server_type, category, position)