feat: add manual invitation generator

This commit is contained in:
Remi
2025-09-17 20:26:33 +02:00
parent f1d18a267b
commit 3e4c1093b8
6 changed files with 221 additions and 2 deletions

View File

@@ -33,3 +33,6 @@ NOTIFY_SMTP_PASS=
# Bootstrap invitations
BOOTSTRAP_INVITE_DEADLINE=2025-09-01T00:00:00Z
# Manual invitation generator
MANUAL_INVITE_PASSWORD=pm.local@zghl.de

View File

@@ -95,6 +95,7 @@ const publicEndpoints = [
{ method: 'POST', path: '/api/service/auth/refresh', description: 'Access-Token anhand des Refresh-Cookies erneuern.' },
{ method: 'GET', path: '/api/service/invitations/{code}', description: 'Einladungscode prüfen (gültig, abgelaufen, verwendet).' },
{ method: 'POST', path: '/api/service/invitations/bootstrap', description: 'Bootstrap-Einladungscode generieren (aktiv bis 01.07.2024, optionales Label).' },
{ method: 'POST', path: '/api/service/invitations/manual', description: 'Manuellen Einladungscode mit Passwortschutz erstellen (intern).' },
]
const protectedEndpoints = [

151
app/pages/invite.vue Normal file
View File

@@ -0,0 +1,151 @@
<template>
<div class="min-h-screen bg-[#050910] px-4 py-12 text-white">
<div class="mx-auto w-full max-w-xl rounded-3xl border border-white/10 bg-white/5 p-8 backdrop-blur">
<div class="mb-8 space-y-1 text-center">
<p class="text-xs uppercase tracking-[0.35em] text-cyan-300/80">OpenSquawk</p>
<h1 class="text-2xl font-semibold">Einladungscode erstellen</h1>
<p class="text-sm text-white/60">Passwortgeschützte Oberfläche für interne Freischaltungen.</p>
</div>
<form class="space-y-6" @submit.prevent="submit">
<div class="space-y-2">
<label for="invite-password" class="block text-sm font-medium text-white/80">Passwort</label>
<input
id="invite-password"
v-model.trim="password"
type="password"
autocomplete="off"
required
class="w-full rounded-lg border border-white/10 bg-black/40 px-4 py-3 text-sm outline-none transition focus:border-cyan-300 focus:ring-1 focus:ring-cyan-300"
placeholder="Passwort eingeben"
>
</div>
<div class="space-y-2">
<label for="invite-label" class="block text-sm font-medium text-white/80">Label (optional)</label>
<input
id="invite-label"
v-model.trim="label"
type="text"
maxlength="80"
class="w-full rounded-lg border border-white/10 bg-black/40 px-4 py-3 text-sm outline-none transition focus:border-cyan-300 focus:ring-1 focus:ring-cyan-300"
placeholder="z. B. Event, Support oder Name"
>
</div>
<button
type="submit"
class="flex w-full items-center justify-center gap-2 rounded-lg bg-cyan-500 px-4 py-3 text-sm font-semibold text-[#050910] transition hover:bg-cyan-400 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="loading"
>
<span v-if="!loading">Einladungscode generieren</span>
<span v-else class="flex items-center gap-2">
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white"></span>
Wird erstellt
</span>
</button>
</form>
<transition name="fade">
<p v-if="errorMessage" class="mt-6 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{{ errorMessage }}
</p>
</transition>
<transition name="fade">
<div
v-if="result"
class="mt-8 space-y-4 rounded-2xl border border-emerald-400/30 bg-emerald-400/10 p-6 text-emerald-100"
>
<div>
<p class="text-xs uppercase tracking-[0.3em] text-emerald-200/70">Einladungscode</p>
<p class="mt-2 font-mono text-3xl tracking-widest text-white">{{ result.code }}</p>
</div>
<div class="grid gap-2 text-sm text-emerald-100/80 sm:grid-cols-2">
<div>
<p class="text-xs uppercase tracking-[0.3em] text-emerald-200/60">Gültig bis</p>
<p>{{ expiresAtDisplay }}</p>
</div>
<div>
<p class="text-xs uppercase tracking-[0.3em] text-emerald-200/60">Label</p>
<p>{{ result.label || 'Kein Label gesetzt' }}</p>
</div>
</div>
</div>
</transition>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useApi } from '~/composables/useApi'
interface ManualInviteResponse {
success: true
code: string
expiresAt: string
label: string | null
}
const api = useApi()
const password = ref('')
const label = ref('')
const loading = ref(false)
const errorMessage = ref<string | null>(null)
const result = ref<ManualInviteResponse | null>(null)
const expiresAtDisplay = computed(() => {
if (!result.value) return ''
const date = new Date(result.value.expiresAt)
if (Number.isNaN(date.getTime())) return result.value.expiresAt
return date.toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
})
useHead({ title: 'Einladungscode erstellen • OpenSquawk' })
async function submit() {
if (loading.value) return
errorMessage.value = null
result.value = null
loading.value = true
try {
const response = await api.post<ManualInviteResponse>(
'/api/service/invitations/manual',
{ password: password.value, label: label.value || undefined },
{ auth: false },
)
result.value = response
password.value = ''
} catch (error: any) {
const message =
error?.data?.statusMessage ||
error?.statusMessage ||
error?.data?.message ||
error?.message ||
'Erstellung des Einladungscodes fehlgeschlagen.'
errorMessage.value = message
} finally {
loading.value = false
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -20,6 +20,7 @@ export default defineNuxtConfig({
ttsModel: process.env.TTS_MODEL || 'tts-1',
jwtSecret: process.env.JWT_SECRET,
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET,
manualInvitePassword: process.env.MANUAL_INVITE_PASSWORD,
mongoose: {
uri: process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/opensquawk',
options: {},

View File

@@ -0,0 +1,63 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import { createError, readBody } from 'h3'
import { useRuntimeConfig } from '#imports'
import { InvitationCode } from '../../../models/InvitationCode'
interface ManualInviteRequestBody {
password?: string
label?: string
}
interface ManualInviteResponse {
success: true
code: string
expiresAt: string
label: string | null
}
function generateCode() {
return randomBytes(4).toString('hex').toUpperCase()
}
function safeComparePassword(provided: string, expected: string) {
const key = 'opensquawk-manual-invite'
const providedDigest = createHmac('sha256', key).update(provided).digest()
const expectedDigest = createHmac('sha256', key).update(expected).digest()
return timingSafeEqual(providedDigest, expectedDigest)
}
export default defineEventHandler<ManualInviteResponse>(async (event) => {
const config = useRuntimeConfig()
const expectedPassword = (config.manualInvitePassword as string | undefined)?.trim() || ''
if (!expectedPassword) {
throw createError({ statusCode: 500, statusMessage: 'Konfiguration für manuellen Einladungscode fehlt' })
}
const body = await readBody<ManualInviteRequestBody>(event).catch(() => ({}) as ManualInviteRequestBody)
const providedPassword = body.password?.trim() || ''
if (!providedPassword || !safeComparePassword(providedPassword, expectedPassword)) {
throw createError({ statusCode: 401, statusMessage: 'Ungültiges Passwort' })
}
const now = new Date()
const code = generateCode()
const expiresAt = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 30)
const label = body.label?.trim() || undefined
await InvitationCode.create({
code,
createdAt: now,
expiresAt,
channel: 'manual',
label,
})
return {
success: true,
code,
expiresAt: expiresAt.toISOString(),
label: label ?? null,
}
})

View File

@@ -9,7 +9,7 @@ export interface InvitationCodeDocument extends mongoose.Document {
expiresAt?: Date
usedBy?: mongoose.Types.ObjectId
usedAt?: Date
channel: 'user' | 'bootstrap'
channel: 'user' | 'bootstrap' | 'manual'
label?: string
}
@@ -20,7 +20,7 @@ const invitationSchema = new mongoose.Schema<InvitationCodeDocument>({
expiresAt: { type: Date },
usedBy: { type: Schema.Types.ObjectId, ref: 'User' },
usedAt: { type: Date },
channel: { type: String, enum: ['user', 'bootstrap'], default: 'user' },
channel: { type: String, enum: ['user', 'bootstrap', 'manual'], default: 'user' },
label: { type: String, trim: true },
})