feat: add invite code system for closed alpha registration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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']);
|
||||
|
||||
64
docs/pow-server/invite-validate.php
Normal file
64
docs/pow-server/invite-validate.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
require __DIR__ . '/config.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => '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]);
|
||||
@@ -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 ? `<div class="auth-error">${this.error}</div>` : ''}
|
||||
|
||||
${this.requireInviteCode ? `
|
||||
<div class="form-group">
|
||||
<label class="label" for="invite-code">${t('auth.inviteCode')}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
id="invite-code"
|
||||
name="invite-code"
|
||||
placeholder="${t('auth.inviteCodePlaceholder')}"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<p class="field-hint">${t('auth.inviteCodeHint')}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="form-group">
|
||||
<pow-captcha id="register-captcha"></pow-captcha>
|
||||
</div>
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -212,7 +212,14 @@
|
||||
"registrationFailed": "Регистрация не удалась",
|
||||
"loginRequired": "Войдите, чтобы продолжить",
|
||||
"rememberMe": "Запомнить на этом устройстве",
|
||||
"rememberMeHint": "Ваш UUID будет сохранён локально. Включайте только на доверенных устройствах."
|
||||
"rememberMeHint": "Ваш UUID будет сохранён локально. Включайте только на доверенных устройствах.",
|
||||
"inviteCode": "Код приглашения",
|
||||
"inviteCodePlaceholder": "напр. ALPHA-XMR-2026",
|
||||
"inviteCodeHint": "Код приглашения необходим во время альфа-фазы.",
|
||||
"inviteCodeRequired": "Введите код приглашения",
|
||||
"inviteCodeInvalid": "Недействительный или просроченный код приглашения",
|
||||
"inviteCodeRedeemed": "Код приглашения полностью использован",
|
||||
"inviteCodeExpired": "Код приглашения истёк"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Избранное",
|
||||
|
||||
Reference in New Issue
Block a user