diff --git a/docs/LAUNCH-PLAN.md b/docs/LAUNCH-PLAN.md index e666f69..1a3b43a 100644 --- a/docs/LAUNCH-PLAN.md +++ b/docs/LAUNCH-PLAN.md @@ -244,10 +244,10 @@ und `twitter:title/description` dynamisch bei jedem Sprachwechsel (i18n-Keys `me ## Checkliste vor Alpha-Start - [ ] Directus: Collection `invite_codes` anlegen (Schema siehe oben) -- [ ] PHP: `invite/validate.php` deployen auf `pow.kashilo.com` +- [x] PHP: `invite/validate.php` deployen auf `pow.kashilo.com` - [ ] PHP: `config.php` → `LISTING_FEE` auf 0.01 setzen -- [ ] PHP: `config.php` → `REQUIRE_INVITE_CODE = true` -- [ ] Frontend: Invite-Code-Feld in `auth-modal.js` einbauen +- [x] PHP: `config.php` → `REQUIRE_INVITE_CODE = true` +- [x] Frontend: Invite-Code-Feld in `auth-modal.js` einbauen - [x] Frontend: Meta-Description i18n-Keys in alle 7 Sprachen - [x] Frontend: Impressum-Seite (Entwurf, alle 7 Sprachen, Platzhalter für Adressdaten) - [x] Frontend: Datenschutz, AGB, Über uns — alle 7 Sprachen diff --git a/docs/pow-server/config.php b/docs/pow-server/config.php index fc1f3f3..cc036ac 100644 --- a/docs/pow-server/config.php +++ b/docs/pow-server/config.php @@ -11,3 +11,5 @@ define('LISTING_FEE', ['EUR' => 1, 'USD' => 1, 'CHF' => 1, 'GBP' => 1, 'JPY' => define('DIRECTUS_URL', getenv('DIRECTUS_URL') ?: 'https://api.kashilo.com'); define('DIRECTUS_TOKEN', getenv('DIRECTUS_TOKEN') ?: 'CHANGE_ME'); + +define('REQUIRE_INVITE_CODE', (bool)(getenv('REQUIRE_INVITE_CODE') ?: true)); diff --git a/docs/pow-server/index.php b/docs/pow-server/index.php index 8a41603..96534e6 100644 --- a/docs/pow-server/index.php +++ b/docs/pow-server/index.php @@ -36,6 +36,9 @@ switch ($uri) { case '/btcpay/webhook': require __DIR__ . '/btcpay-webhook.php'; break; + case '/invite/validate': + require __DIR__ . '/invite-validate.php'; + break; default: http_response_code(404); echo json_encode(['error' => 'Not found']); diff --git a/docs/pow-server/invite-validate.php b/docs/pow-server/invite-validate.php new file mode 100644 index 0000000..83f2e3c --- /dev/null +++ b/docs/pow-server/invite-validate.php @@ -0,0 +1,64 @@ + 'Method not allowed']); + exit; +} + +if (!REQUIRE_INVITE_CODE) { + echo json_encode(['valid' => true]); + exit; +} + +$input = json_decode(file_get_contents('php://input'), true); +$code = trim($input['code'] ?? ''); + +if (!$code) { + http_response_code(400); + echo json_encode(['valid' => false, 'error' => 'Missing invite code']); + exit; +} + +$url = DIRECTUS_URL . '/items/invite_codes?filter[code][_eq]=' . urlencode($code) + . '&filter[status][_eq]=active&limit=1'; + +$context = stream_context_create([ + 'http' => [ + 'header' => "Authorization: Bearer " . DIRECTUS_TOKEN . "\r\n", + 'ignore_errors' => true, + ], +]); + +$response = file_get_contents($url, false, $context); +$data = json_decode($response, true); +$invite = $data['data'][0] ?? null; + +if (!$invite) { + echo json_encode(['valid' => false, 'error' => 'invalid_code']); + exit; +} + +if ($invite['max_uses'] > 0 && $invite['used_count'] >= $invite['max_uses']) { + echo json_encode(['valid' => false, 'error' => 'code_redeemed']); + exit; +} + +if ($invite['expires_at'] && strtotime($invite['expires_at']) < time()) { + echo json_encode(['valid' => false, 'error' => 'code_expired']); + exit; +} + +$updateUrl = DIRECTUS_URL . '/items/invite_codes/' . $invite['id']; +$updateContext = stream_context_create([ + 'http' => [ + 'method' => 'PATCH', + 'header' => "Content-Type: application/json\r\nAuthorization: Bearer " . DIRECTUS_TOKEN . "\r\n", + 'content' => json_encode(['used_count' => $invite['used_count'] + 1]), + 'ignore_errors' => true, + ], +]); +file_get_contents($updateUrl, false, $updateContext); + +echo json_encode(['valid' => true]); diff --git a/js/components/auth-modal.js b/js/components/auth-modal.js index d634546..197b08f 100644 --- a/js/components/auth-modal.js +++ b/js/components/auth-modal.js @@ -4,6 +4,7 @@ import { t, i18n } from '../i18n.js' import { auth } from '../services/auth.js' +import { POW_SERVER } from '../services/pow-captcha.js' import './pow-captcha.js' class AuthModal extends HTMLElement { @@ -14,14 +15,30 @@ class AuthModal extends HTMLElement { this.error = null this.loading = false this.loginAttempts = 0 + this.requireInviteCode = null } connectedCallback() { + this.checkInviteCodeRequired() this.render() this.unsubscribe = i18n.subscribe(() => this.render()) this.boundHandleKeydown = this.handleKeydown.bind(this) } + async checkInviteCodeRequired() { + try { + const res = await fetch(`${POW_SERVER}/invite/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: '' }) + }) + const data = await res.json() + this.requireInviteCode = !data.valid + } catch { + this.requireInviteCode = false + } + } + disconnectedCallback() { if (this.unsubscribe) this.unsubscribe() document.removeEventListener('keydown', this.boundHandleKeydown) @@ -141,6 +158,22 @@ class AuthModal extends HTMLElement { ${this.error ? `
${this.error}
` : ''} + ${this.requireInviteCode ? ` +
+ + +

${t('auth.inviteCodeHint')}

+
+ ` : ''} +
@@ -274,6 +307,39 @@ class AuthModal extends HTMLElement { } async handleRegister() { + // Validate invite code if required + if (this.requireInviteCode) { + const inviteInput = this.querySelector('#invite-code') + const code = inviteInput?.value.trim() + if (!code) { + this.error = t('auth.inviteCodeRequired') + this.render() + return + } + + try { + const res = await fetch(`${POW_SERVER}/invite/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }) + }) + const data = await res.json() + if (!data.valid) { + const errorMap = { + 'code_redeemed': t('auth.inviteCodeRedeemed'), + 'code_expired': t('auth.inviteCodeExpired') + } + this.error = errorMap[data.error] || t('auth.inviteCodeInvalid') + this.render() + return + } + } catch { + this.error = t('auth.inviteCodeInvalid') + this.render() + return + } + } + // Require captcha for registration const captcha = this.querySelector('#register-captcha') if (!captcha?.isSolved()) { diff --git a/js/services/pow-captcha.js b/js/services/pow-captcha.js index aef1478..6d120cf 100644 --- a/js/services/pow-captcha.js +++ b/js/services/pow-captcha.js @@ -2,7 +2,7 @@ // Client must find nonce where SHA256(challenge + nonce) has N leading zeros // Server-first: tries /pow/challenge endpoint, falls back to local generation -const POW_SERVER = 'https://pow.kashilo.com' +export const POW_SERVER = 'https://pow.kashilo.com' const DIFFICULTY = 4 const SERVER_TIMEOUT_MS = 1500 diff --git a/locales/de.json b/locales/de.json index 190e784..44a3202 100644 --- a/locales/de.json +++ b/locales/de.json @@ -212,7 +212,14 @@ "registrationFailed": "Registrierung fehlgeschlagen", "loginRequired": "Bitte melde dich an, um fortzufahren", "rememberMe": "Auf diesem Gerät merken", - "rememberMeHint": "Deine UUID wird lokal gespeichert. Nur aktivieren auf vertrauenswürdigen Geräten." + "rememberMeHint": "Deine UUID wird lokal gespeichert. Nur aktivieren auf vertrauenswürdigen Geräten.", + "inviteCode": "Einladungscode", + "inviteCodePlaceholder": "z.B. ALPHA-XMR-2026", + "inviteCodeHint": "Für die Alpha-Phase wird ein Einladungscode benötigt.", + "inviteCodeRequired": "Bitte Einladungscode eingeben", + "inviteCodeInvalid": "Ungültiger oder abgelaufener Einladungscode", + "inviteCodeRedeemed": "Einladungscode bereits vollständig eingelöst", + "inviteCodeExpired": "Einladungscode abgelaufen" }, "favorites": { "title": "Favoriten", diff --git a/locales/en.json b/locales/en.json index 250f3ac..fa0edd8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -212,7 +212,14 @@ "registrationFailed": "Registration failed", "loginRequired": "Please log in to continue", "rememberMe": "Remember me on this device", - "rememberMeHint": "Your UUID will be stored locally. Only enable on trusted devices." + "rememberMeHint": "Your UUID will be stored locally. Only enable on trusted devices.", + "inviteCode": "Invite Code", + "inviteCodePlaceholder": "e.g. ALPHA-XMR-2026", + "inviteCodeHint": "An invite code is required during the alpha phase.", + "inviteCodeRequired": "Please enter an invite code", + "inviteCodeInvalid": "Invalid or expired invite code", + "inviteCodeRedeemed": "Invite code fully redeemed", + "inviteCodeExpired": "Invite code expired" }, "favorites": { "title": "Favorites", diff --git a/locales/es.json b/locales/es.json index 2a6d933..a3d9071 100644 --- a/locales/es.json +++ b/locales/es.json @@ -212,7 +212,14 @@ "registrationFailed": "Error en el registro", "loginRequired": "Inicia sesión para continuar", "rememberMe": "Recordarme en este dispositivo", - "rememberMeHint": "Tu UUID se guardará localmente. Actívalo solo en dispositivos de confianza." + "rememberMeHint": "Tu UUID se guardará localmente. Actívalo solo en dispositivos de confianza.", + "inviteCode": "Código de invitación", + "inviteCodePlaceholder": "ej. ALPHA-XMR-2026", + "inviteCodeHint": "Se requiere un código de invitación durante la fase alfa.", + "inviteCodeRequired": "Introduce un código de invitación", + "inviteCodeInvalid": "Código de invitación no válido o caducado", + "inviteCodeRedeemed": "Código de invitación completamente canjeado", + "inviteCodeExpired": "Código de invitación caducado" }, "favorites": { "title": "Favoritos", diff --git a/locales/fr.json b/locales/fr.json index 627437b..b036ba0 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -212,7 +212,14 @@ "registrationFailed": "Échec de l'inscription", "loginRequired": "Veuillez vous connecter pour continuer", "rememberMe": "Se souvenir de moi", - "rememberMeHint": "Votre UUID sera stocké localement. N'activez que sur des appareils de confiance." + "rememberMeHint": "Votre UUID sera stocké localement. N'activez que sur des appareils de confiance.", + "inviteCode": "Code d'invitation", + "inviteCodePlaceholder": "ex. ALPHA-XMR-2026", + "inviteCodeHint": "Un code d'invitation est requis pendant la phase alpha.", + "inviteCodeRequired": "Veuillez entrer un code d'invitation", + "inviteCodeInvalid": "Code d'invitation invalide ou expiré", + "inviteCodeRedeemed": "Code d'invitation entièrement utilisé", + "inviteCodeExpired": "Code d'invitation expiré" }, "favorites": { "title": "Favoris", diff --git a/locales/it.json b/locales/it.json index b0e7281..5107ca6 100644 --- a/locales/it.json +++ b/locales/it.json @@ -212,7 +212,14 @@ "registrationFailed": "Registrazione fallita", "loginRequired": "Accedi per continuare", "rememberMe": "Ricordami su questo dispositivo", - "rememberMeHint": "Il tuo UUID verrà salvato localmente. Attiva solo su dispositivi affidabili." + "rememberMeHint": "Il tuo UUID verrà salvato localmente. Attiva solo su dispositivi affidabili.", + "inviteCode": "Codice invito", + "inviteCodePlaceholder": "es. ALPHA-XMR-2026", + "inviteCodeHint": "Un codice invito è richiesto durante la fase alpha.", + "inviteCodeRequired": "Inserisci un codice invito", + "inviteCodeInvalid": "Codice invito non valido o scaduto", + "inviteCodeRedeemed": "Codice invito completamente utilizzato", + "inviteCodeExpired": "Codice invito scaduto" }, "favorites": { "title": "Preferiti", diff --git a/locales/pt.json b/locales/pt.json index b35fd86..f119d08 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -212,7 +212,14 @@ "registrationFailed": "Falha no cadastro", "loginRequired": "Por favor, faça login para continuar", "rememberMe": "Lembrar neste dispositivo", - "rememberMeHint": "Seu UUID será armazenado localmente. Ative apenas em dispositivos confiáveis." + "rememberMeHint": "Seu UUID será armazenado localmente. Ative apenas em dispositivos confiáveis.", + "inviteCode": "Código de convite", + "inviteCodePlaceholder": "ex. ALPHA-XMR-2026", + "inviteCodeHint": "Um código de convite é necessário durante a fase alfa.", + "inviteCodeRequired": "Insira um código de convite", + "inviteCodeInvalid": "Código de convite inválido ou expirado", + "inviteCodeRedeemed": "Código de convite totalmente resgatado", + "inviteCodeExpired": "Código de convite expirado" }, "favorites": { "title": "Favoritos", diff --git a/locales/ru.json b/locales/ru.json index 1c9fa8f..02b1980 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -212,7 +212,14 @@ "registrationFailed": "Регистрация не удалась", "loginRequired": "Войдите, чтобы продолжить", "rememberMe": "Запомнить на этом устройстве", - "rememberMeHint": "Ваш UUID будет сохранён локально. Включайте только на доверенных устройствах." + "rememberMeHint": "Ваш UUID будет сохранён локально. Включайте только на доверенных устройствах.", + "inviteCode": "Код приглашения", + "inviteCodePlaceholder": "напр. ALPHA-XMR-2026", + "inviteCodeHint": "Код приглашения необходим во время альфа-фазы.", + "inviteCodeRequired": "Введите код приглашения", + "inviteCodeInvalid": "Недействительный или просроченный код приглашения", + "inviteCodeRedeemed": "Код приглашения полностью использован", + "inviteCodeExpired": "Код приглашения истёк" }, "favorites": { "title": "Избранное",