feat: Passwordless cross-device authentication

- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
This commit is contained in:
Bernt
2026-07-07 07:11:50 +00:00
parent 4aa984ad74
commit 6989a98d75
61843 changed files with 5491611 additions and 872231 deletions
+34 -34
View File
@@ -38,7 +38,7 @@
.container { max-width: 680px; margin: 0 auto; padding: 24px 16px 48px; }
/* Section */
.section { background: var(--surface); border-radius: 16px; padding: 20px; margin-bottom: 16px; }
.section { background: var(--surface); border-radius: var(--radius-lg); padding: 20px; margin-bottom: 16px; }
.section-title { font-size: 17px; font-weight: 600; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
/* Form fields */
@@ -47,7 +47,7 @@
.field-label { font-size: 13px; font-weight: 500; color: var(--text2); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.3px; }
.field-input {
width: 100%; background: var(--surface2); border: 1.5px solid var(--border);
border-radius: 12px; padding: 12px 14px; font-size: 15px; color: var(--text);
border-radius: var(--radius-md); padding: 12px 14px; font-size: 15px; color: var(--text);
font-family: var(--font); transition: border-color 0.15s;
}
.field-input:focus { outline: none; border-color: var(--blue); }
@@ -56,7 +56,7 @@
select.field-input { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236e6e73' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 14px center; padding-right: 36px; }
/* Map picker */
#location-map { width: 100%; height: 220px; border-radius: 12px; overflow: hidden; margin-top: 10px; border: 1.5px solid var(--border); }
#location-map { width: 100%; height: 220px; border-radius: var(--radius-md); overflow: hidden; margin-top: 10px; border: 1.5px solid var(--border); }
.location-addr { margin-top: 8px; font-size: 13px; color: var(--text2); display: flex; align-items: center; gap: 6px; }
.location-pin { color: var(--accent); font-size: 16px; }
@@ -64,12 +64,12 @@
.slider-wrap { margin-top: 8px; }
.budget-display { font-size: 24px; font-weight: 700; color: var(--accent); margin-bottom: 10px; }
input[type="range"] {
-webkit-appearance: none; width: 100%; height: 4px; border-radius: 2px;
-webkit-appearance: none; width: 100%; height: 4px; border-radius: var(--radius-sm);
background: linear-gradient(to right, var(--accent) 0%, var(--accent) var(--pct, 20%), var(--surface2) var(--pct, 20%), var(--surface2) 100%);
outline: none; cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 22px; height: 22px; border-radius: 50%;
-webkit-appearance: none; width: 22px; height: 22px; border-radius: var(--radius-full);
background: var(--accent); box-shadow: 0 2px 8px rgba(0,0,0,0.25); cursor: pointer;
}
.budget-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--text2); margin-top: 4px; }
@@ -77,7 +77,7 @@
/* Camera count */
.camera-selector { display: flex; align-items: center; gap: 14px; }
.count-btn {
width: 36px; height: 36px; border-radius: 50%; background: var(--surface2);
width: 36px; height: 36px; border-radius: var(--radius-full); background: var(--surface2);
border: 1.5px solid var(--border); font-size: 20px; cursor: pointer; display: flex;
align-items: center; justify-content: center; color: var(--text); transition: background 0.1s;
}
@@ -87,7 +87,7 @@
/* Type selector */
.type-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.type-btn {
background: var(--surface2); border: 1.5px solid var(--border); border-radius: 12px;
background: var(--surface2); border: 1.5px solid var(--border); border-radius: var(--radius-md);
padding: 12px 8px; text-align: center; cursor: pointer; transition: all 0.15s;
}
.type-btn:hover { border-color: var(--blue); }
@@ -99,7 +99,7 @@
.submit-section { padding: 0 0 8px; }
.submit-btn {
width: 100%; padding: 16px; background: var(--accent); color: white; border: none;
border-radius: 16px; font-size: 17px; font-weight: 600; cursor: pointer;
border-radius: var(--radius-lg); font-size: 17px; font-weight: 600; cursor: pointer;
font-family: var(--font); transition: opacity 0.15s; display: flex; align-items: center; justify-content: center; gap: 8px;
}
.submit-btn:hover:not(:disabled) { opacity: 0.88; }
@@ -111,14 +111,14 @@
.confirm-title { font-size: 26px; font-weight: 700; letter-spacing: -0.5px; margin-bottom: 8px; }
.confirm-sub { font-size: 15px; color: var(--text2); margin-bottom: 28px; line-height: 1.5; }
.mission-id-box {
background: var(--surface2); border: 1px solid var(--border); border-radius: 14px;
background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: 16px; margin-bottom: 24px;
}
.mission-id-label { font-size: 12px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.mission-id-val { font-size: 22px; font-weight: 700; font-feature-settings: 'tnum'; letter-spacing: 2px; }
.track-btn {
display: inline-block; padding: 14px 32px; background: var(--blue); color: white;
border-radius: 14px; font-size: 16px; font-weight: 600; text-decoration: none;
border-radius: var(--radius-lg); font-size: 16px; font-weight: 600; text-decoration: none;
transition: opacity 0.15s;
}
.track-btn:hover { opacity: 0.88; }
@@ -174,14 +174,14 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- Location -->
<div class="section">
<div class="section-title">📍 Plats</div>
<div class="section-title"> Plats</div>
<div class="field">
<div class="field-label">Adress</div>
<input class="field-input" id="address" type="text" placeholder="Ange adress eller klicka på kartan…" oninput="updateAddress(this.value)"/>
</div>
<div id="location-map"></div>
<div class="location-addr">
<span class="location-pin">📌</span>
<span class="location-pin"></span>
<span id="chosen-location">Klicka på kartan för att välja plats</span>
</div>
<input type="hidden" id="lat" value="7.9519"/>
@@ -190,7 +190,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- Mission details -->
<div class="section">
<div class="section-title">📋 Uppdragsdetaljer</div>
<div class="section-title"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="2" width="10" height="12" rx="1" stroke="#666" stroke-width="1.5"/><path d="M6 5H10M6 8H10M6 11H8" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg> Uppdragsdetaljer</div>
<div class="field">
<div class="field-label">Titel / Beskrivning av uppdrag</div>
<input class="field-input" id="title" type="text" placeholder="T.ex. Hotell-lobby fotografering…"/>
@@ -203,27 +203,27 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="field-label">Typ av uppdrag</div>
<div class="type-grid">
<div class="type-btn active" data-type="photography" onclick="selectType(this)">
<div class="type-icon">📷</div>
<div class="type-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="4" width="12" height="9" rx="2" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="8.5" r="2.5" stroke="#666" stroke-width="1.5"/><path d="M5 4L6 2H10L11 4" stroke="#666" stroke-width="1.5"/></svg></div>
<div class="type-label">Foto</div>
</div>
<div class="type-btn" data-type="video" onclick="selectType(this)">
<div class="type-icon">🎬</div>
<div class="type-icon"></div>
<div class="type-label">Video</div>
</div>
<div class="type-btn" data-type="drone" onclick="selectType(this)">
<div class="type-icon">🚁</div>
<div class="type-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 2V6M4 4H12" stroke="#666" stroke-width="1.5" stroke-linecap="round"/><path d="M2 8H6L8 6L10 8H14" stroke="#666" stroke-width="1.5" stroke-linejoin="round"/><path d="M6 8V12H10V8" stroke="#666" stroke-width="1.5"/></svg></div>
<div class="type-label">Drone</div>
</div>
<div class="type-btn" data-type="livestream" onclick="selectType(this)">
<div class="type-icon">📡</div>
<div class="type-icon"></div>
<div class="type-label">Livestream</div>
</div>
<div class="type-btn" data-type="360" onclick="selectType(this)">
<div class="type-icon">🔄</div>
<div class="type-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 8C2 11.3 4.7 14 8 14C10.5 14 12.7 12.5 13.7 10.3" stroke="#666" stroke-width="1.5" stroke-linecap="round"/><path d="M14 8C14 4.7 11.3 2 8 2C5.5 2 3.3 3.5 2.3 5.7" stroke="#666" stroke-width="1.5" stroke-linecap="round"/><path d="M13.5 7.5L14 10L11.5 9.5" stroke="#666" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.5 8.5L2 6L4.5 6.5" stroke="#666" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="type-label">360°</div>
</div>
<div class="type-btn" data-type="documentary" onclick="selectType(this)">
<div class="type-icon">🎥</div>
<div class="type-icon"></div>
<div class="type-label">Dokumentär</div>
</div>
</div>
@@ -233,7 +233,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- Date & time -->
<div class="section">
<div class="section-title">📅 Datum & Tid</div>
<div class="section-title"> Datum & Tid</div>
<div class="field">
<div class="field-label">Startdatum och tid</div>
<input class="field-input" id="date-time" type="datetime-local"/>
@@ -242,7 +242,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- Cameras -->
<div class="section">
<div class="section-title">📸 Antal Zoomers</div>
<div class="section-title"> Antal Zoomers</div>
<div class="field">
<div class="field-label">Önskat antal kameror / Zoomers</div>
<div class="camera-selector">
@@ -256,7 +256,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- Budget -->
<div class="section">
<div class="section-title">💰 Budget</div>
<div class="section-title"> Budget</div>
<div class="field">
<div class="field-label">Budget (SEK)</div>
<div class="slider-wrap">
@@ -270,7 +270,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- Contact -->
<div class="section">
<div class="section-title">👤 Dina uppgifter</div>
<div class="section-title"> Dina uppgifter</div>
<div class="field">
<div class="field-label">Namn</div>
<input class="field-input" id="contact-name" type="text" placeholder="För- och efternamn"/>
@@ -288,7 +288,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- Submit -->
<div class="submit-section">
<button class="submit-btn" id="submit-btn" onclick="submitMission()">
📤 Skicka beställning
Skicka beställning
</button>
<div style="text-align:center;font-size:12px;color:var(--text2);margin-top:10px;">
Du bekräftar att uppdraget följer quiXzooms riktlinjer. Vi återkommer inom 30 minuter.
@@ -299,19 +299,19 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- Confirmation screen -->
<div class="confirm-screen" id="confirm-screen">
<div class="confirm-icon">🎉</div>
<div class="confirm-icon"></div>
<div class="confirm-title">Uppdrag beställt!</div>
<div class="confirm-sub">Vi har tagit emot din beställning och matchar dig med rätt Zoomer. Du får en bekräftelse via e-post inom kort.</div>
<div class="mission-id-box">
<div class="mission-id-label">Uppdragets ID</div>
<div class="mission-id-val" id="confirm-mission-id"></div>
</div>
<a class="track-btn" id="track-link" href="#">🗺️ Följ uppdraget live</a>
<a class="track-btn" id="track-link" href="#"> Följ uppdraget live</a>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// ── Map ───────────────────────────────────────────────────────────────────
// Map
const map = L.map('location-map', { center: [7.9519, 98.2849], zoom: 12 });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
@@ -339,21 +339,21 @@ function updateAddress(addr) {
document.getElementById('chosen-location').textContent = addr || 'Klicka på kartan för att välja plats';
}
// ── Type selector ─────────────────────────────────────────────────────────
// Type selector
function selectType(el) {
document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('active'));
el.classList.add('active');
document.getElementById('mission-type').value = el.dataset.type;
}
// ── Camera count ──────────────────────────────────────────────────────────
// Camera count
let cameraCount = 1;
function changeCount(delta) {
cameraCount = Math.max(1, Math.min(10, cameraCount + delta));
document.getElementById('camera-count').textContent = cameraCount;
}
// ── Budget slider ─────────────────────────────────────────────────────────
// Budget slider
function updateBudget(val) {
document.getElementById('budget').value = val;
document.getElementById('budget-display').textContent = parseInt(val).toLocaleString('sv-SE') + ' kr';
@@ -362,13 +362,13 @@ function updateBudget(val) {
}
updateBudget(5000);
// ── Default date/time (tomorrow 10:00) ────────────────────────────────────
// Default date/time (tomorrow 10:00)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(10, 0, 0, 0);
document.getElementById('date-time').value = tomorrow.toISOString().slice(0, 16);
// ── Submit ────────────────────────────────────────────────────────────────
// Submit
async function submitMission() {
const title = document.getElementById('title').value.trim();
const contactName = document.getElementById('contact-name').value.trim();
@@ -410,12 +410,12 @@ async function submitMission() {
document.getElementById('track-link').href = `/quixzoom/mission-map.html?mission=${d.mission.id}`;
} else {
btn.disabled = false;
btn.textContent = '📤 Skicka beställning';
btn.textContent = ' Skicka beställning';
alert(d.error || 'Något gick fel. Försök igen.');
}
} catch(e) {
btn.disabled = false;
btn.textContent = '📤 Skicka beställning';
btn.textContent = ' Skicka beställning';
alert('Nätverksfel. Kontrollera din anslutning.');
}
}