mirror of
https://github.com/wizarrrr/wizarr.git
synced 2025-12-23 23:59:23 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user