mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 08:55:54 +08:00
feat: add manual invitation generator
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
151
app/pages/invite.vue
Normal 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>
|
||||
@@ -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: {},
|
||||
|
||||
63
server/api/service/invitations/manual.post.ts
Normal file
63
server/api/service/invitations/manual.post.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user