feat: add email invitation functionality for guests with support for multiple addresses
Build & Push Docker Image / build (push) Successful in 4m21s

This commit is contained in:
2026-04-02 00:54:57 +02:00
parent 61585d8c63
commit 1690a74c19
7 changed files with 214 additions and 15 deletions
+6 -1
View File
@@ -514,14 +514,19 @@
"inbox": "Einladungen",
"inboxSubtitle": "Meeting-Einladungen von anderen Redlight-Instanzen",
"inviteTitle": "Remote-Benutzer einladen",
"inviteSubtitle": "Einen Benutzer von einer anderen Redlight-Instanz zu diesem Meeting einladen.",
"inviteSubtitle": "Du kannst entweder einen Benutzer von einer anderen Redlight-Instanz über seine Adresse einladen oder direkt eine E-Mail-Einladung senden. Es kann nur eine Option gleichzeitig verwendet werden.",
"addressLabel": "Benutzeradresse",
"addressPlaceholder": "@benutzer@andere-instanz.com",
"addressHint": "Format: @Benutzername@Domain der Redlight-Instanz",
"emailLabel": "Oder per E-Mail einladen",
"emailPlaceholder": "name@beispiel.de, name2@beispiel.de",
"emailHint": "Eine oder mehrere E-Mail-Adressen, durch Komma getrennt",
"messageLabel": "Nachricht (optional)",
"messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!",
"send": "Einladung senden",
"sent": "Einladung gesendet!",
"emailSent": "E-Mail-Einladung(en) gesendet!",
"emailSendFailed": "E-Mail-Einladung konnte nicht gesendet werden",
"sendFailed": "Einladung konnte nicht gesendet werden",
"from": "Von",
"accept": "Annehmen",
+6 -1
View File
@@ -514,14 +514,19 @@
"inbox": "Invitations",
"inboxSubtitle": "Meeting invitations from other Redlight instances",
"inviteTitle": "Invite Remote User",
"inviteSubtitle": "Invite a user from another Redlight instance to this meeting.",
"inviteSubtitle": "You can either invite a user from another Redlight instance by their address, or send an email invitation directly. Only one option can be used at a time.",
"addressLabel": "User address",
"addressPlaceholder": "@user@other-instance.com",
"addressHint": "Format: @username@domain of the Redlight instance",
"emailLabel": "Or invite by email",
"emailPlaceholder": "name@example.com, name2@example.com",
"emailHint": "Enter one or more email addresses, separated by commas",
"messageLabel": "Message (optional)",
"messagePlaceholder": "Hi, I'd like to invite you to our meeting!",
"send": "Send invitation",
"sent": "Invitation sent!",
"emailSent": "Email invitation(s) sent!",
"emailSendFailed": "Could not send email invitation",
"sendFailed": "Could not send invitation",
"from": "From",
"accept": "Accept",
+60 -13
View File
@@ -51,6 +51,7 @@ export default function RoomDetail() {
// Federation invite state
const [showFedInvite, setShowFedInvite] = useState(false);
const [fedAddress, setFedAddress] = useState('');
const [fedEmails, setFedEmails] = useState('');
const [fedMessage, setFedMessage] = useState('');
const [fedSending, setFedSending] = useState(false);
@@ -266,25 +267,51 @@ export default function RoomDetail() {
const handleFedInvite = async (e) => {
e.preventDefault();
// Accept @user@domain or user@domain — must have a domain part
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
if (!normalized.includes('@') || normalized.endsWith('@')) {
const hasAddress = fedAddress.trim().length > 0;
const hasEmails = fedEmails.trim().length > 0;
if (!hasAddress && !hasEmails) {
toast.error(t('federation.addressHint'));
return;
}
setFedSending(true);
try {
await api.post('/federation/invite', {
room_uid: uid,
to: fedAddress,
message: fedMessage || undefined,
});
toast.success(t('federation.sent'));
if (hasAddress) {
// Federation address mode
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
if (!normalized.includes('@') || normalized.endsWith('@')) {
toast.error(t('federation.addressHint'));
setFedSending(false);
return;
}
await api.post('/federation/invite', {
room_uid: uid,
to: fedAddress,
message: fedMessage || undefined,
});
toast.success(t('federation.sent'));
} else {
// Email mode
const emailList = fedEmails.split(',').map(e => e.trim()).filter(Boolean);
if (emailList.length === 0) {
toast.error(t('federation.emailHint'));
setFedSending(false);
return;
}
await api.post('/rooms/invite-email', {
room_uid: uid,
emails: emailList,
message: fedMessage || undefined,
});
toast.success(t('federation.emailSent'));
}
setShowFedInvite(false);
setFedAddress('');
setFedEmails('');
setFedMessage('');
} catch (err) {
toast.error(err.response?.data?.error || t('federation.sendFailed'));
toast.error(err.response?.data?.error || t(hasAddress ? 'federation.sendFailed' : 'federation.emailSendFailed'));
} finally {
setFedSending(false);
}
@@ -857,13 +884,33 @@ export default function RoomDetail() {
<input
type="text"
value={fedAddress}
onChange={e => setFedAddress(e.target.value)}
onChange={e => { setFedAddress(e.target.value); if (e.target.value) setFedEmails(''); }}
className="input-field"
placeholder={t('federation.addressPlaceholder')}
required
disabled={fedEmails.trim().length > 0}
/>
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
</div>
<div className="flex items-center gap-3 my-2">
<div className="flex-1 border-t border-th-border" />
<span className="text-xs text-th-text-s uppercase">{t('common.or')}</span>
<div className="flex-1 border-t border-th-border" />
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.emailLabel')}</label>
<input
type="text"
value={fedEmails}
onChange={e => { setFedEmails(e.target.value); if (e.target.value) setFedAddress(''); }}
className="input-field"
placeholder={t('federation.emailPlaceholder')}
disabled={fedAddress.trim().length > 0}
/>
<p className="text-xs text-th-text-s mt-1">{t('federation.emailHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
<textarea
@@ -878,7 +925,7 @@ export default function RoomDetail() {
<button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1">
{t('common.cancel')}
</button>
<button type="submit" disabled={fedSending} className="btn-primary flex-1">
<button type="submit" disabled={fedSending || (!fedAddress.trim() && !fedEmails.trim())} className="btn-primary flex-1">
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
{t('federation.send')}
</button>