Ekran — Logowanie
Opis
Ekran logowania to jedyny ekran dostepny bez autoryzacji. Umozliwia logowanie dwoma metodami:
- Kod z aplikacji — 8-znakowy kod alfanumeryczny (wielkie litery + cyfry) wygenerowany w aplikacji mobilnej
- Email — wyslanie kodu weryfikacyjnego na adres email
Design: centrowana karta na szarym tle z logo powyzej.
ASCII Wireframe
+------------------------------------------------------------------+
| |
| bg-gray-50 |
| |
| +------------------+ |
| | LUMOS ISLAND | |
| | (logo) | |
| +------------------+ |
| |
| +----------------------------------+ |
| | | |
| | [ Kod z aplikacji ] [ Email ] | <- tabs |
| | ────────────────── ────────── | |
| | | |
| | Tab 1 (aktywna): | |
| | | |
| | Wpisz kod z aplikacji dziecka | |
| | | |
| | [ _ ] [ _ ] [ _ ] [ _ ] [ _ ] [ _ ] [ _ ] [ _ ] |
| | | |
| | Kod znajdziesz w ustawieniach | |
| | aplikacji mobilnej. | |
| | | |
| +----------------------------------+ |
| | | |
| | Tab 2 (ukryta): | |
| | | |
| | [ email@example.com ] | |
| | [ Wyslij kod ] | |
| | | |
| | (po wyslaniu): | |
| | [ _ ] [ _ ] [ _ ] [ _ ] [ _ ] [ _ ] [ _ ] [ _ ] |
| | [ Weryfikuj ] | |
| | | |
| +----------------------------------+ |
| |
| Nie masz konta? Pobierz aplikacje. |
| |
+------------------------------------------------------------------+Struktura HTML
Layout strony logowania
html
<body class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="flex justify-center mb-8">
<img src="/static/img/logo.svg" alt="Lumos Island" class="h-12">
</div>
<!-- Karta logowania -->
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<!-- Tabs -->
<div class="flex border-b border-gray-100">
<button hx-get="/partials/auth/code-tab"
hx-target="#auth-content"
hx-swap="innerHTML transition:true"
class="flex-1 px-4 py-3.5 text-sm font-medium text-center
transition-colors duration-150
border-b-2 border-indigo-600 text-indigo-600"
id="tab-code"
@click="activeTab = 'code'"
:class="activeTab === 'code'
? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700'">
Kod z aplikacji
</button>
<button hx-get="/partials/auth/email-tab"
hx-target="#auth-content"
hx-swap="innerHTML transition:true"
class="flex-1 px-4 py-3.5 text-sm font-medium text-center
transition-colors duration-150
border-b-2 border-transparent text-gray-500 hover:text-gray-700"
id="tab-email"
@click="activeTab = 'email'"
:class="activeTab === 'email'
? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700'">
Email
</button>
</div>
<!-- Tresc taba (podmieniana przez HTMX) -->
<div id="auth-content" class="p-6" x-data="{ activeTab: 'code' }">
<!-- Domyslnie: tab z kodem -->
{{template "partials/auth/code-tab" .}}
</div>
</div>
<!-- Footer -->
<p class="mt-6 text-center text-sm text-gray-500">
Nie masz konta?
<a href="https://lumos-island.app" class="text-indigo-600 hover:text-indigo-800 font-medium">
Pobierz aplikacje
</a>
</p>
</div>
</body>Tab 1: Kod z aplikacji
Partial HTML
html
{{/* partials/auth/code-tab.html */}}
<div x-data="codeInput()" class="space-y-5">
<div class="text-center">
<h2 class="text-lg font-display font-semibold text-gray-800">
Wpisz kod z aplikacji
</h2>
<p class="mt-1.5 text-sm text-gray-500">
Kod znajdziesz w ustawieniach aplikacji mobilnej dziecka.
</p>
</div>
<!-- 8-znakowy input alfanumeryczny (wielkie litery + cyfry) -->
<div class="flex justify-center gap-2">
<template x-for="(char, index) in chars" :key="index">
<input type="text"
maxlength="1"
inputmode="text"
pattern="[A-Z0-9]"
x-model="chars[index]"
@input="handleInput($event, index)"
@keydown.backspace="handleBackspace($event, index)"
@paste="handlePaste($event)"
:x-ref="'char-' + index"
class="w-11 h-14 text-center text-xl font-semibold text-gray-900 uppercase
border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500
transition-colors duration-150">
</template>
</div>
<!-- Blad walidacji -->
<div x-show="error" x-cloak
class="text-center text-sm text-red-600">
<span x-text="error"></span>
</div>
<!-- Loading indicator -->
<div x-show="loading" x-cloak class="flex justify-center">
<svg class="animate-spin w-5 h-5 text-indigo-600" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</div>
</div>Alpine.js — logika kodu
html
<script>
function codeInput() {
return {
chars: ['', '', '', '', '', '', '', ''],
error: '',
loading: false,
handleInput(event, index) {
const value = event.target.value.toUpperCase();
if (!/^[A-Z0-9]$/.test(value)) {
this.chars[index] = '';
return;
}
this.chars[index] = value;
// Przejdz do nastepnego pola
if (index < 7) {
this.$refs['char-' + (index + 1)].focus();
}
// Auto-submit gdy wszystkie pola wypelnione
if (this.chars.every(c => c !== '')) {
this.submitCode();
}
},
handleBackspace(event, index) {
if (this.chars[index] === '' && index > 0) {
this.$refs['char-' + (index - 1)].focus();
}
},
handlePaste(event) {
event.preventDefault();
const pasted = event.clipboardData.getData('text').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
for (let i = 0; i < pasted.length; i++) {
this.chars[i] = pasted[i];
}
if (pasted.length === 8) {
this.submitCode();
}
},
async submitCode() {
this.loading = true;
this.error = '';
const code = this.chars.join('');
try {
const res = await fetch('/api/auth/verify-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
if (res.ok) {
window.location.href = '/dashboard';
} else {
const data = await res.json();
this.error = data.message || 'Nieprawidlowy kod. Sprobuj ponownie.';
this.chars = ['', '', '', '', '', '', '', ''];
this.$refs['char-0'].focus();
}
} catch {
this.error = 'Blad polaczenia. Sprobuj ponownie.';
} finally {
this.loading = false;
}
}
}
}
</script>Tab 2: Email
Partial HTML
html
{{/* partials/auth/email-tab.html */}}
<div x-data="emailAuth()" class="space-y-5">
<div class="text-center">
<h2 class="text-lg font-display font-semibold text-gray-800">
Zaloguj sie emailem
</h2>
<p class="mt-1.5 text-sm text-gray-500">
Wysliemy kod weryfikacyjny na Twoj adres email.
</p>
</div>
<!-- Krok 1: Wpisz email -->
<template x-if="step === 'email'">
<form @submit.prevent="sendCode()" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">
Adres email
</label>
<input type="email"
x-model="email"
required
placeholder="twoj@email.com"
class="w-full px-3.5 py-2.5 text-sm text-gray-900
border border-gray-300 rounded-lg
placeholder:text-gray-400
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500
transition-colors duration-150">
</div>
<button type="submit"
:disabled="loading"
class="w-full px-4 py-2.5 bg-indigo-600 text-white text-sm font-medium
rounded-lg shadow-sm hover:bg-indigo-700
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors duration-150">
<span x-show="!loading">Wyslij kod</span>
<span x-show="loading" class="inline-flex items-center gap-2">
<svg class="animate-spin w-4 h-4" viewBox="0 0 24 24">...</svg>
Wysylanie...
</span>
</button>
</form>
</template>
<!-- Krok 2: Wpisz kod z emaila -->
<template x-if="step === 'verify'">
<div class="space-y-4">
<p class="text-sm text-gray-600 text-center">
Wyslalem kod na <span class="font-medium text-gray-900" x-text="email"></span>
</p>
<!-- 8-znakowy input alfanumeryczny (taka sama logika jak w tab z kodem) -->
<div class="flex justify-center gap-2">
<template x-for="(char, index) in verifyChars" :key="index">
<input type="text" maxlength="1" inputmode="text"
pattern="[A-Z0-9]"
x-model="verifyChars[index]"
@input="handleVerifyInput($event, index)"
@keydown.backspace="handleVerifyBackspace($event, index)"
class="w-11 h-14 text-center text-xl font-semibold text-gray-900 uppercase
border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500
transition-colors duration-150">
</template>
</div>
<button @click="step = 'email'"
class="w-full text-center text-sm text-gray-500 hover:text-gray-700">
Zmien adres email
</button>
</div>
</template>
<!-- Blad -->
<div x-show="error" x-cloak class="text-center text-sm text-red-600">
<span x-text="error"></span>
</div>
</div>Endpointy API
POST /api/auth/verify-code
Weryfikacja 8-znakowego kodu alfanumerycznego (z aplikacji lub z emaila).
Request:
json
{
"code": "B5T8N3P1"
}Response (200):
json
{
"token": "eyJhbG...",
"redirect": "/dashboard"
}Response (401):
json
{
"message": "Nieprawidlowy lub wygasly kod."
}POST /api/auth/send-email-code
Wyslanie kodu weryfikacyjnego na email.
Request:
json
{
"email": "rodzic@example.com"
}Response (200):
json
{
"message": "Kod wyslany na podany adres email."
}Response (429):
json
{
"message": "Zbyt wiele prob. Sprobuj za 60 sekund."
}Go Handler
go
func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Walidacja formatu kodu (8 znaków, wielkie litery + cyfry)
if len(req.Code) != 8 {
writeJSON(w, http.StatusBadRequest, map[string]string{
"message": "Kod musi miec 8 znakow (wielkie litery i cyfry).",
})
return
}
// Weryfikacja kodu w bazie
session, err := authService.VerifyCode(r.Context(), req.Code)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"message": "Nieprawidlowy lub wygasly kod.",
})
return
}
// Ustawienie sesji (cookie)
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: session.Token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400 * 30, // 30 dni
})
writeJSON(w, http.StatusOK, map[string]string{
"redirect": "/dashboard",
})
}Routing (web)
go
// Strona logowania (GET)
mux.HandleFunc("GET /login", LoginPageHandler)
// Partiale tabow (HTMX)
mux.HandleFunc("GET /partials/auth/code-tab", CodeTabPartialHandler)
mux.HandleFunc("GET /partials/auth/email-tab", EmailTabPartialHandler)
// API auth
mux.HandleFunc("POST /api/auth/verify-code", VerifyCodeHandler)
mux.HandleFunc("POST /api/auth/send-email-code", SendEmailCodeHandler)