feat: production routing support for subdomain and custom domain modes
Some checks failed
CI / ruff (push) Successful in 10s
CI / pytest (push) Failing after 45m18s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 30s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

Double-mount store routes at /store/* and /store/{store_code}/* so the
same handlers work in dev path-based, prod path-based, prod subdomain,
and prod custom-domain modes.  Wire StorePlatform.custom_subdomain into
StoreContextMiddleware for per-platform subdomain overrides.  Add admin
custom-domain management UI, fix stale /shop/ reset link, add
/merchants/ to reserved paths, and server-render window.STORE_CODE for
JS that previously parsed the URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 00:15:06 +01:00
parent 6a82d7c12d
commit ce5b54f27b
36 changed files with 822 additions and 151 deletions

View File

@@ -10,7 +10,7 @@ Store pages for authentication and account management:
- Settings
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
@@ -18,6 +18,7 @@ from app.api.deps import (
get_current_store_from_cookie_or_header,
get_current_store_optional,
get_db,
get_resolved_store_code,
)
from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User
@@ -31,24 +32,13 @@ router = APIRouter()
# ============================================================================
@router.get("/{store_code}", response_class=RedirectResponse, include_in_schema=False)
async def store_root_no_slash(store_code: str = Path(..., description="Store code")):
"""
Redirect /store/{code} (no trailing slash) to login page.
Handles requests without trailing slash.
"""
return RedirectResponse(url=f"/store/{store_code}/login", status_code=302)
@router.get(
"/{store_code}/", response_class=RedirectResponse, include_in_schema=False
)
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
async def store_root(
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User | None = Depends(get_current_store_optional),
):
"""
Redirect /store/{code}/ based on authentication status.
Redirect /store/ or /store/{code}/ based on authentication status.
- Authenticated store users -> /store/{code}/dashboard
- Unauthenticated users -> /store/{code}/login
@@ -62,11 +52,11 @@ async def store_root(
@router.get(
"/{store_code}/login", response_class=HTMLResponse, include_in_schema=False
"/login", response_class=HTMLResponse, include_in_schema=False
)
async def store_login_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User | None = Depends(get_current_store_optional),
):
"""
@@ -74,11 +64,6 @@ async def store_login_page(
If user is already authenticated as store, redirect to dashboard.
Otherwise, show login form.
JavaScript will:
- Load store info via API
- Handle login form submission
- Redirect to dashboard on success
"""
if current_user:
return RedirectResponse(
@@ -100,11 +85,11 @@ async def store_login_page(
@router.get(
"/{store_code}/team", response_class=HTMLResponse, include_in_schema=False
"/team", response_class=HTMLResponse, include_in_schema=False
)
async def store_team_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -119,11 +104,11 @@ async def store_team_page(
@router.get(
"/{store_code}/profile", response_class=HTMLResponse, include_in_schema=False
"/profile", response_class=HTMLResponse, include_in_schema=False
)
async def store_profile_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -138,11 +123,11 @@ async def store_profile_page(
@router.get(
"/{store_code}/settings", response_class=HTMLResponse, include_in_schema=False
"/settings", response_class=HTMLResponse, include_in_schema=False
)
async def store_settings_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):

View File

@@ -20,6 +20,13 @@ function adminStoreDetail() {
showDeleteStoreModal: false,
showDeleteStoreFinalModal: false,
// Domain management state
domains: [],
domainsLoading: false,
showAddDomainForm: false,
domainSaving: false,
newDomain: { domain: '', platform_id: '' },
// Initialize
async init() {
// Load i18n translations
@@ -42,9 +49,12 @@ function adminStoreDetail() {
this.storeCode = match[1];
detailLog.info('Viewing store:', this.storeCode);
await this.loadStore();
// Load subscription after store is loaded
// Load subscription and domains after store is loaded
if (this.store?.id) {
await this.loadSubscriptions();
await Promise.all([
this.loadSubscriptions(),
this.loadDomains(),
]);
}
} else {
detailLog.error('No store code in URL');
@@ -180,6 +190,90 @@ function adminStoreDetail() {
}
},
// ====================================================================
// DOMAIN MANAGEMENT
// ====================================================================
async loadDomains() {
if (!this.store?.id) return;
this.domainsLoading = true;
try {
const url = `/admin/stores/${this.store.id}/domains`;
const response = await apiClient.get(url);
this.domains = response.domains || [];
detailLog.info('Domains loaded:', this.domains.length);
} catch (error) {
if (error.status === 404) {
this.domains = [];
} else {
detailLog.warn('Failed to load domains:', error.message);
}
} finally {
this.domainsLoading = false;
}
},
async addDomain() {
if (!this.newDomain.domain || this.domainSaving) return;
this.domainSaving = true;
try {
const payload = { domain: this.newDomain.domain };
if (this.newDomain.platform_id) {
payload.platform_id = parseInt(this.newDomain.platform_id);
}
await apiClient.post(`/admin/stores/${this.store.id}/domains`, payload);
Utils.showToast('Domain added successfully', 'success');
this.showAddDomainForm = false;
this.newDomain = { domain: '', platform_id: '' };
await this.loadDomains();
} catch (error) {
Utils.showToast(error.message || 'Failed to add domain', 'error');
} finally {
this.domainSaving = false;
}
},
async verifyDomain(domainId) {
try {
const response = await apiClient.post(`/admin/stores/domains/${domainId}/verify`);
Utils.showToast(response.message || 'Domain verified!', 'success');
await this.loadDomains();
} catch (error) {
Utils.showToast(error.message || 'Verification failed — check DNS records', 'error');
}
},
async toggleDomainActive(domainId, activate) {
try {
await apiClient.put(`/admin/stores/domains/${domainId}`, { is_active: activate });
Utils.showToast(activate ? 'Domain activated' : 'Domain deactivated', 'success');
await this.loadDomains();
} catch (error) {
Utils.showToast(error.message || 'Failed to update domain', 'error');
}
},
async setDomainPrimary(domainId) {
try {
await apiClient.put(`/admin/stores/domains/${domainId}`, { is_primary: true });
Utils.showToast('Domain set as primary', 'success');
await this.loadDomains();
} catch (error) {
Utils.showToast(error.message || 'Failed to set primary domain', 'error');
}
},
async deleteDomain(domainId, domainName) {
if (!confirm(`Delete domain "${domainName}"? This cannot be undone.`)) return;
try {
await apiClient.delete(`/admin/stores/domains/${domainId}`);
Utils.showToast('Domain deleted', 'success');
await this.loadDomains();
} catch (error) {
Utils.showToast(error.message || 'Failed to delete domain', 'error');
}
},
// Refresh store data
async refresh() {
detailLog.info('=== STORE REFRESH TRIGGERED ===');

View File

@@ -356,6 +356,142 @@
</div>
</div>
<!-- Custom Domains -->
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
Custom Domains
</h3>
<button
@click="showAddDomainForm = !showAddDomainForm"
class="flex items-center px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('plus', 'w-4 h-4 mr-1')"></span>
Add Domain
</button>
</div>
<!-- Add Domain Form -->
<div x-show="showAddDomainForm" x-transition class="mb-4 p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">Domain</label>
<input
type="text"
x-model="newDomain.domain"
placeholder="shop.example.com"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500" />
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">Platform</label>
<select
x-model="newDomain.platform_id"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500">
<option value="">Select platform...</option>
<template x-for="entry in subscriptions" :key="entry.subscription?.id">
<option :value="entry.platform_id" x-text="entry.platform_name"></option>
</template>
</select>
</div>
</div>
<div class="flex justify-end gap-2 mt-3">
<button
@click="showAddDomainForm = false; newDomain = {domain: '', platform_id: ''}"
class="px-3 py-1.5 text-sm font-medium text-gray-600 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-300 dark:hover:bg-gray-500">
Cancel
</button>
<button
@click="addDomain()"
:disabled="!newDomain.domain || domainSaving"
class="px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!domainSaving">Add Domain</span>
<span x-show="domainSaving">Adding...</span>
</button>
</div>
</div>
<!-- Domains Loading -->
<div x-show="domainsLoading" class="text-center py-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading domains...</p>
</div>
<!-- Domains List -->
<div x-show="!domainsLoading && domains.length > 0" class="space-y-3">
<template x-for="domain in domains" :key="domain.id">
<div class="p-3 bg-gray-50 rounded-lg dark:bg-gray-700 flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm font-medium text-gray-700 dark:text-gray-200 truncate" x-text="domain.domain"></span>
<span x-show="domain.is_primary"
class="px-1.5 py-0.5 text-xs font-medium text-purple-800 bg-purple-100 rounded-full dark:bg-purple-900 dark:text-purple-300">
Primary
</span>
<span :class="domain.is_verified
? 'text-green-800 bg-green-100 dark:bg-green-900 dark:text-green-300'
: 'text-yellow-800 bg-yellow-100 dark:bg-yellow-900 dark:text-yellow-300'"
class="px-1.5 py-0.5 text-xs font-medium rounded-full"
x-text="domain.is_verified ? 'Verified' : 'Unverified'">
</span>
<span :class="domain.is_active
? 'text-green-800 bg-green-100 dark:bg-green-900 dark:text-green-300'
: 'text-red-800 bg-red-100 dark:bg-red-900 dark:text-red-300'"
class="px-1.5 py-0.5 text-xs font-medium rounded-full"
x-text="domain.is_active ? 'Active' : 'Inactive'">
</span>
<span x-show="domain.ssl_status === 'active'"
class="px-1.5 py-0.5 text-xs font-medium text-green-800 bg-green-100 rounded-full dark:bg-green-900 dark:text-green-300">
SSL
</span>
</div>
<!-- Verification instructions (shown when unverified) -->
<div x-show="!domain.is_verified && domain.verification_token" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Add TXT record: <code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">_orion-verify.<span x-text="domain.domain"></span></code>
with value <code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs" x-text="domain.verification_token"></code>
</div>
</div>
<div class="flex items-center gap-1 ml-3 flex-shrink-0">
<!-- Verify Button -->
<button
x-show="!domain.is_verified"
@click="verifyDomain(domain.id)"
class="px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
title="Verify DNS">
<span x-html="$icon('badge-check', 'w-4 h-4')"></span>
</button>
<!-- Toggle Active -->
<button
x-show="domain.is_verified"
@click="toggleDomainActive(domain.id, !domain.is_active)"
:class="domain.is_active ? 'text-yellow-600 hover:text-yellow-700' : 'text-green-600 hover:text-green-700'"
class="px-2 py-1 text-xs font-medium"
:title="domain.is_active ? 'Deactivate' : 'Activate'">
<span x-html="$icon(domain.is_active ? 'eye-off' : 'eye', 'w-4 h-4')"></span>
</button>
<!-- Set Primary -->
<button
x-show="domain.is_verified && !domain.is_primary"
@click="setDomainPrimary(domain.id)"
class="px-2 py-1 text-xs font-medium text-purple-600 hover:text-purple-700 dark:text-purple-400"
title="Set as primary">
<span x-html="$icon('star', 'w-4 h-4')"></span>
</button>
<!-- Delete -->
<button
@click="deleteDomain(domain.id, domain.domain)"
class="px-2 py-1 text-xs font-medium text-red-600 hover:text-red-700 dark:text-red-400"
title="Delete domain">
<span x-html="$icon('delete', 'w-4 h-4')"></span>
</button>
</div>
</div>
</template>
</div>
<!-- No Domains -->
<div x-show="!domainsLoading && domains.length === 0" class="text-center py-4">
<p class="text-sm text-gray-500 dark:text-gray-400">No custom domains configured.</p>
</div>
</div>
<!-- More Actions -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">