Add display name support for user management and update related components
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m2s

This commit is contained in:
2026-02-27 16:29:23 +01:00
parent d781022b63
commit 9be9938f02
14 changed files with 165 additions and 63 deletions

View File

@@ -127,6 +127,7 @@ export async function initDatabase() {
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
display_name TEXT DEFAULT '',
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')), role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')),
@@ -213,6 +214,7 @@ export async function initDatabase() {
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
display_name TEXT DEFAULT '',
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')), role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')),
@@ -343,6 +345,10 @@ export async function initDatabase() {
if (!(await db.columnExists('federation_invitations', 'allow_recording'))) { if (!(await db.columnExists('federation_invitations', 'allow_recording'))) {
await db.exec('ALTER TABLE federation_invitations ADD COLUMN allow_recording INTEGER DEFAULT 1'); await db.exec('ALTER TABLE federation_invitations ADD COLUMN allow_recording INTEGER DEFAULT 1');
} }
if (!(await db.columnExists('users', 'display_name'))) {
await db.exec("ALTER TABLE users ADD COLUMN display_name TEXT DEFAULT ''");
await db.exec("UPDATE users SET display_name = name WHERE display_name = ''");
}
// ── Default admin ─────────────────────────────────────────────────────── // ── Default admin ───────────────────────────────────────────────────────
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com'; const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';

View File

@@ -14,7 +14,7 @@ export async function authenticateToken(req, res, next) {
try { try {
const decoded = jwt.verify(token, JWT_SECRET); const decoded = jwt.verify(token, JWT_SECRET);
const db = getDb(); const db = getDb();
const user = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [decoded.userId]); const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [decoded.userId]);
if (!user) { if (!user) {
return res.status(401).json({ error: 'User not found' }); return res.status(401).json({ error: 'User not found' });
} }

View File

@@ -8,12 +8,17 @@ const router = Router();
// POST /api/admin/users - Create user (admin) // POST /api/admin/users - Create user (admin)
router.post('/users', authenticateToken, requireAdmin, async (req, res) => { router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
try { try {
const { name, email, password, role } = req.body; const { name, display_name, email, password, role } = req.body;
if (!name || !email || !password) { if (!name || !display_name || !email || !password) {
return res.status(400).json({ error: 'All fields are required' }); return res.status(400).json({ error: 'All fields are required' });
} }
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
}
if (password.length < 6) { if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters long' }); return res.status(400).json({ error: 'Password must be at least 6 characters long' });
} }
@@ -26,13 +31,18 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
return res.status(409).json({ error: 'Email is already in use' }); return res.status(409).json({ error: 'Email is already in use' });
} }
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?)', [name]);
if (existingUsername) {
return res.status(409).json({ error: 'Username is already taken' });
}
const hash = bcrypt.hashSync(password, 12); const hash = bcrypt.hashSync(password, 12);
const result = await db.run( const result = await db.run(
'INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)', 'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
[name, email.toLowerCase(), hash, validRole] [name, display_name, email.toLowerCase(), hash, validRole]
); );
const user = await db.get('SELECT id, name, email, role, created_at FROM users WHERE id = ?', [result.lastInsertRowid]); const user = await db.get('SELECT id, name, display_name, email, role, created_at FROM users WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ user }); res.status(201).json({ user });
} catch (err) { } catch (err) {
console.error('Create user error:', err); console.error('Create user error:', err);
@@ -45,7 +55,7 @@ router.get('/users', authenticateToken, requireAdmin, async (req, res) => {
try { try {
const db = getDb(); const db = getDb();
const users = await db.all(` const users = await db.all(`
SELECT id, name, email, role, language, theme, avatar_color, avatar_image, created_at, SELECT id, name, display_name, email, role, language, theme, avatar_color, avatar_image, created_at,
(SELECT COUNT(*) FROM rooms WHERE rooms.user_id = users.id) as room_count (SELECT COUNT(*) FROM rooms WHERE rooms.user_id = users.id) as room_count
FROM users FROM users
ORDER BY created_at DESC ORDER BY created_at DESC

View File

@@ -22,12 +22,17 @@ const router = Router();
// POST /api/auth/register // POST /api/auth/register
router.post('/register', async (req, res) => { router.post('/register', async (req, res) => {
try { try {
const { name, email, password } = req.body; const { username, display_name, email, password } = req.body;
if (!name || !email || !password) { if (!username || !display_name || !email || !password) {
return res.status(400).json({ error: 'All fields are required' }); return res.status(400).json({ error: 'All fields are required' });
} }
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(username)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
}
if (password.length < 6) { if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters long' }); return res.status(400).json({ error: 'Password must be at least 6 characters long' });
} }
@@ -38,6 +43,11 @@ router.post('/register', async (req, res) => {
return res.status(409).json({ error: 'Email is already in use' }); return res.status(409).json({ error: 'Email is already in use' });
} }
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?)', [username]);
if (existingUsername) {
return res.status(409).json({ error: 'Username is already taken' });
}
const hash = bcrypt.hashSync(password, 12); const hash = bcrypt.hashSync(password, 12);
// If SMTP is configured, require email verification // If SMTP is configured, require email verification
@@ -46,8 +56,8 @@ router.post('/register', async (req, res) => {
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
await db.run( await db.run(
'INSERT INTO users (name, email, password_hash, email_verified, verification_token, verification_token_expires) VALUES (?, ?, ?, 0, ?, ?)', 'INSERT INTO users (name, display_name, email, password_hash, email_verified, verification_token, verification_token_expires) VALUES (?, ?, ?, ?, 0, ?, ?)',
[name, email.toLowerCase(), hash, verificationToken, expires] [username, display_name, email.toLowerCase(), hash, verificationToken, expires]
); );
// Build verification URL // Build verification URL
@@ -61,19 +71,19 @@ router.post('/register', async (req, res) => {
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {} try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
} }
await sendVerificationEmail(email.toLowerCase(), name, verifyUrl, appName); await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName);
return res.status(201).json({ needsVerification: true, message: 'Verification email has been sent' }); return res.status(201).json({ needsVerification: true, message: 'Verification email has been sent' });
} }
// No SMTP configured register and login immediately (legacy behaviour) // No SMTP configured register and login immediately (legacy behaviour)
const result = await db.run( const result = await db.run(
'INSERT INTO users (name, email, password_hash, email_verified) VALUES (?, ?, ?, 1)', 'INSERT INTO users (name, display_name, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)',
[name, email.toLowerCase(), hash] [username, display_name, email.toLowerCase(), hash]
); );
const token = generateToken(result.lastInsertRowid); const token = generateToken(result.lastInsertRowid);
const user = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [result.lastInsertRowid]); const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ token, user }); res.status(201).json({ token, user });
} catch (err) { } catch (err) {
@@ -129,7 +139,7 @@ router.post('/resend-verification', async (req, res) => {
} }
const db = getDb(); const db = getDb();
const user = await db.get('SELECT id, name, email_verified FROM users WHERE email = ?', [email.toLowerCase()]); const user = await db.get('SELECT id, name, display_name, email_verified FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user || user.email_verified) { if (!user || user.email_verified) {
// Don't reveal whether account exists // Don't reveal whether account exists
@@ -153,7 +163,7 @@ router.post('/resend-verification', async (req, res) => {
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {} try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
} }
await sendVerificationEmail(email.toLowerCase(), user.name, verifyUrl, appName); await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName);
res.json({ message: 'If an account exists, a new email has been sent.' }); res.json({ message: 'If an account exists, a new email has been sent.' });
} catch (err) { } catch (err) {
@@ -200,7 +210,7 @@ router.get('/me', authenticateToken, (req, res) => {
// PUT /api/auth/profile // PUT /api/auth/profile
router.put('/profile', authenticateToken, async (req, res) => { router.put('/profile', authenticateToken, async (req, res) => {
try { try {
const { name, email, theme, language, avatar_color } = req.body; const { name, display_name, email, theme, language, avatar_color } = req.body;
const db = getDb(); const db = getDb();
if (email && email !== req.user.email) { if (email && email !== req.user.email) {
@@ -210,18 +220,30 @@ router.put('/profile', authenticateToken, async (req, res) => {
} }
} }
if (name && name !== req.user.name) {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
}
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, req.user.id]);
if (existingUsername) {
return res.status(409).json({ error: 'Username is already taken' });
}
}
await db.run(` await db.run(`
UPDATE users SET UPDATE users SET
name = COALESCE(?, name), name = COALESCE(?, name),
display_name = COALESCE(?, display_name),
email = COALESCE(?, email), email = COALESCE(?, email),
theme = COALESCE(?, theme), theme = COALESCE(?, theme),
language = COALESCE(?, language), language = COALESCE(?, language),
avatar_color = COALESCE(?, avatar_color), avatar_color = COALESCE(?, avatar_color),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
`, [name, email?.toLowerCase(), theme, language, avatar_color, req.user.id]); `, [name, display_name, email?.toLowerCase(), theme, language, avatar_color, req.user.id]);
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]); const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated }); res.json({ user: updated });
} catch (err) { } catch (err) {
console.error('Profile update error:', err); console.error('Profile update error:', err);
@@ -290,7 +312,7 @@ router.post('/avatar', authenticateToken, async (req, res) => {
fs.writeFileSync(filepath, buffer); fs.writeFileSync(filepath, buffer);
await db.run('UPDATE users SET avatar_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [filename, req.user.id]); await db.run('UPDATE users SET avatar_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [filename, req.user.id]);
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]); const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated }); res.json({ user: updated });
} catch (err) { } catch (err) {
@@ -309,7 +331,7 @@ router.delete('/avatar', authenticateToken, async (req, res) => {
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
} }
await db.run('UPDATE users SET avatar_image = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]); await db.run('UPDATE users SET avatar_image = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]); const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated }); res.json({ user: updated });
} catch (err) { } catch (err) {
console.error('Avatar delete error:', err); console.error('Avatar delete error:', err);

View File

@@ -27,7 +27,7 @@ router.get('/', authenticateToken, async (req, res) => {
try { try {
const db = getDb(); const db = getDb();
const ownRooms = await db.all(` const ownRooms = await db.all(`
SELECT r.*, u.name as owner_name, 0 as shared SELECT r.*, COALESCE(NULLIF(u.display_name,''), u.name) as owner_name, 0 as shared
FROM rooms r FROM rooms r
JOIN users u ON r.user_id = u.id JOIN users u ON r.user_id = u.id
WHERE r.user_id = ? WHERE r.user_id = ?
@@ -35,7 +35,7 @@ router.get('/', authenticateToken, async (req, res) => {
`, [req.user.id]); `, [req.user.id]);
const sharedRooms = await db.all(` const sharedRooms = await db.all(`
SELECT r.*, u.name as owner_name, 1 as shared SELECT r.*, COALESCE(NULLIF(u.display_name,''), u.name) as owner_name, 1 as shared
FROM rooms r FROM rooms r
JOIN users u ON r.user_id = u.id JOIN users u ON r.user_id = u.id
JOIN room_shares rs ON rs.room_id = r.id JOIN room_shares rs ON rs.room_id = r.id
@@ -60,11 +60,11 @@ router.get('/users/search', authenticateToken, async (req, res) => {
const db = getDb(); const db = getDb();
const searchTerm = `%${q}%`; const searchTerm = `%${q}%`;
const users = await db.all(` const users = await db.all(`
SELECT id, name, email, avatar_color, avatar_image SELECT id, name, display_name, email, avatar_color, avatar_image
FROM users FROM users
WHERE (name LIKE ? OR email LIKE ?) AND id != ? WHERE (name LIKE ? OR display_name LIKE ? OR email LIKE ?) AND id != ?
LIMIT 10 LIMIT 10
`, [searchTerm, searchTerm, req.user.id]); `, [searchTerm, searchTerm, searchTerm, req.user.id]);
res.json({ users }); res.json({ users });
} catch (err) { } catch (err) {
console.error('Search users error:', err); console.error('Search users error:', err);
@@ -77,7 +77,7 @@ router.get('/:uid', authenticateToken, async (req, res) => {
try { try {
const db = getDb(); const db = getDb();
const room = await db.get(` const room = await db.get(`
SELECT r.*, u.name as owner_name SELECT r.*, COALESCE(NULLIF(u.display_name,''), u.name) as owner_name
FROM rooms r FROM rooms r
JOIN users u ON r.user_id = u.id JOIN users u ON r.user_id = u.id
WHERE r.uid = ? WHERE r.uid = ?
@@ -98,7 +98,7 @@ router.get('/:uid', authenticateToken, async (req, res) => {
// Get shared users // Get shared users
const sharedUsers = await db.all(` const sharedUsers = await db.all(`
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM room_shares rs FROM room_shares rs
JOIN users u ON rs.user_id = u.id JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ? WHERE rs.room_id = ?
@@ -254,7 +254,7 @@ router.get('/:uid/shares', authenticateToken, async (req, res) => {
return res.status(404).json({ error: 'Room not found or no permission' }); return res.status(404).json({ error: 'Room not found or no permission' });
} }
const shares = await db.all(` const shares = await db.all(`
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM room_shares rs FROM room_shares rs
JOIN users u ON rs.user_id = u.id JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ? WHERE rs.room_id = ?
@@ -288,7 +288,7 @@ router.post('/:uid/shares', authenticateToken, async (req, res) => {
} }
await db.run('INSERT INTO room_shares (room_id, user_id) VALUES (?, ?)', [room.id, user_id]); await db.run('INSERT INTO room_shares (room_id, user_id) VALUES (?, ?)', [room.id, user_id]);
const shares = await db.all(` const shares = await db.all(`
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM room_shares rs FROM room_shares rs
JOIN users u ON rs.user_id = u.id JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ? WHERE rs.room_id = ?
@@ -310,7 +310,7 @@ router.delete('/:uid/shares/:userId', authenticateToken, async (req, res) => {
} }
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, parseInt(req.params.userId)]); await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, parseInt(req.params.userId)]);
const shares = await db.all(` const shares = await db.all(`
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM room_shares rs FROM room_shares rs
JOIN users u ON rs.user_id = u.id JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ? WHERE rs.room_id = ?
@@ -420,7 +420,7 @@ router.get('/:uid/public', async (req, res) => {
const db = getDb(); const db = getDb();
const room = await db.get(` const room = await db.get(`
SELECT r.uid, r.name, r.welcome_message, r.access_code, r.record_meeting, r.max_participants, r.anyone_can_start, SELECT r.uid, r.name, r.welcome_message, r.access_code, r.record_meeting, r.max_participants, r.anyone_can_start,
u.name as owner_name COALESCE(NULLIF(u.display_name,''), u.name) as owner_name
FROM rooms r FROM rooms r
JOIN users u ON r.user_id = u.id JOIN users u ON r.user_id = u.id
WHERE r.uid = ? WHERE r.uid = ?

View File

@@ -27,8 +27,8 @@ export default function Navbar({ onMenuClick }) {
navigate('/'); navigate('/');
}; };
const initials = user?.name const initials = (user?.display_name || user?.name)
? user.name ? (user.display_name || user.name)
.split(' ') .split(' ')
.map(n => n[0]) .map(n => n[0])
.join('') .join('')
@@ -72,14 +72,14 @@ export default function Navbar({ onMenuClick }) {
)} )}
</div> </div>
<span className="hidden md:block text-sm font-medium text-th-text"> <span className="hidden md:block text-sm font-medium text-th-text">
{user?.name} {user?.display_name || user?.name}
</span> </span>
</button> </button>
{dropdownOpen && ( {dropdownOpen && (
<div className="absolute right-0 mt-2 w-56 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden"> <div className="absolute right-0 mt-2 w-56 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
<div className="px-4 py-3 border-b border-th-border"> <div className="px-4 py-3 border-b border-th-border">
<p className="text-sm font-medium text-th-text">{user?.name}</p> <p className="text-sm font-medium text-th-text">{user?.display_name || user?.name}</p>
<p className="text-xs text-th-text-s">{user?.email}</p> <p className="text-xs text-th-text-s">{user?.email}</p>
</div> </div>
<div className="py-1"> <div className="py-1">

View File

@@ -106,10 +106,10 @@ export default function Sidebar({ open, onClose }) {
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0" className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0"
style={{ backgroundColor: user?.avatar_color || '#6366f1' }} style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
> >
{user?.name?.[0]?.toUpperCase() || '?'} {(user?.display_name || user?.name)?.[0]?.toUpperCase() || '?'}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-th-text truncate">{user?.name}</p> <p className="text-sm font-medium text-th-text truncate">{user?.display_name || user?.name}</p>
<p className="text-xs text-th-text-s truncate">{user?.email}</p> <p className="text-xs text-th-text-s truncate">{user?.email}</p>
</div> </div>
</div> </div>

View File

@@ -28,8 +28,8 @@ export function AuthProvider({ children }) {
return res.data.user; return res.data.user;
}, []); }, []);
const register = useCallback(async (name, email, password) => { const register = useCallback(async (username, displayName, email, password) => {
const res = await api.post('/auth/register', { name, email, password }); const res = await api.post('/auth/register', { username, display_name: displayName, email, password });
if (res.data.needsVerification) { if (res.data.needsVerification) {
return { needsVerification: true }; return { needsVerification: true };
} }

View File

@@ -72,7 +72,16 @@
"verifyFailed": "Verifizierung fehlgeschlagen", "verifyFailed": "Verifizierung fehlgeschlagen",
"verifyFailedTitle": "Verifizierung fehlgeschlagen", "verifyFailedTitle": "Verifizierung fehlgeschlagen",
"verifyTokenMissing": "Kein Verifizierungstoken vorhanden.", "verifyTokenMissing": "Kein Verifizierungstoken vorhanden.",
"emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach." "emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.",
"username": "Benutzername",
"usernamePlaceholder": "z.B. maxmuster",
"usernameHint": "Nur Buchstaben, Zahlen, _ und - erlaubt (330 Zeichen)",
"displayName": "Anzeigename",
"displayNamePlaceholder": "Max Mustermann",
"usernameTaken": "Benutzername ist bereits vergeben",
"usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten (330 Zeichen)",
"usernameRequired": "Benutzername ist erforderlich",
"displayNameRequired": "Anzeigename ist erforderlich"
}, },
"home": { "home": {
"poweredBy": "Powered by BigBlueButton", "poweredBy": "Powered by BigBlueButton",

View File

@@ -72,7 +72,16 @@
"verifyFailed": "Verification failed", "verifyFailed": "Verification failed",
"verifyFailedTitle": "Verification failed", "verifyFailedTitle": "Verification failed",
"verifyTokenMissing": "No verification token provided.", "verifyTokenMissing": "No verification token provided.",
"emailNotVerified": "Email not yet verified. Please check your inbox." "emailNotVerified": "Email not yet verified. Please check your inbox.",
"username": "Username",
"usernamePlaceholder": "e.g. johndoe",
"usernameHint": "Letters, numbers, _ and - only (330 chars)",
"displayName": "Display Name",
"displayNamePlaceholder": "John Doe",
"usernameTaken": "Username is already taken",
"usernameInvalid": "Username may only contain letters, numbers, _ and - (330 chars)",
"usernameRequired": "Username is required",
"displayNameRequired": "Display name is required"
}, },
"home": { "home": {
"poweredBy": "Powered by BigBlueButton", "poweredBy": "Powered by BigBlueButton",

View File

@@ -25,7 +25,7 @@ export default function Admin() {
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [showCreateUser, setShowCreateUser] = useState(false); const [showCreateUser, setShowCreateUser] = useState(false);
const [creatingUser, setCreatingUser] = useState(false); const [creatingUser, setCreatingUser] = useState(false);
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'user' }); const [newUser, setNewUser] = useState({ name: '', display_name: '', email: '', password: '', role: 'user' });
// Branding state // Branding state
const [editAppName, setEditAppName] = useState(''); const [editAppName, setEditAppName] = useState('');
@@ -163,7 +163,7 @@ export default function Admin() {
await api.post('/admin/users', newUser); await api.post('/admin/users', newUser);
toast.success(t('admin.userCreated')); toast.success(t('admin.userCreated'));
setShowCreateUser(false); setShowCreateUser(false);
setNewUser({ name: '', email: '', password: '', role: 'user' }); setNewUser({ name: '', display_name: '', email: '', password: '', role: 'user' });
fetchUsers(); fetchUsers();
} catch (err) { } catch (err) {
toast.error(err.response?.data?.error || t('admin.userCreateFailed')); toast.error(err.response?.data?.error || t('admin.userCreateFailed'));
@@ -173,7 +173,7 @@ export default function Admin() {
}; };
const filteredUsers = users.filter(u => const filteredUsers = users.filter(u =>
u.name.toLowerCase().includes(search.toLowerCase()) || (u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase()) u.email.toLowerCase().includes(search.toLowerCase())
); );
@@ -371,12 +371,12 @@ export default function Admin() {
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
u.name[0]?.toUpperCase() (u.display_name || u.name)[0]?.toUpperCase()
)} )}
</div> </div>
<div> <div>
<p className="text-sm font-medium text-th-text">{u.name}</p> <p className="text-sm font-medium text-th-text">{u.display_name || u.name}</p>
<p className="text-xs text-th-text-s">{u.email}</p> <p className="text-xs text-th-text-s">@{u.name} · {u.email}</p>
</div> </div>
</div> </div>
</td> </td>
@@ -490,7 +490,7 @@ export default function Admin() {
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.createUserTitle')}</h3> <h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.createUserTitle')}</h3>
<form onSubmit={handleCreateUser} className="space-y-4"> <form onSubmit={handleCreateUser} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label> <label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<div className="relative"> <div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input <input
@@ -498,10 +498,24 @@ export default function Admin() {
value={newUser.name} value={newUser.name}
onChange={e => setNewUser({ ...newUser, name: e.target.value })} onChange={e => setNewUser({ ...newUser, name: e.target.value })}
className="input-field pl-11" className="input-field pl-11"
placeholder={t('auth.namePlaceholder')} placeholder={t('auth.usernamePlaceholder')}
required required
/> />
</div> </div>
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={newUser.display_name}
onChange={e => setNewUser({ ...newUser, display_name: e.target.value })}
className="input-field pl-11"
placeholder={t('auth.displayNamePlaceholder')}
/>
</div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label> <label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>

View File

@@ -7,7 +7,8 @@ import BrandLogo from '../components/BrandLogo';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
export default function Register() { export default function Register() {
const [name, setName] = useState(''); const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
@@ -32,7 +33,7 @@ export default function Register() {
setLoading(true); setLoading(true);
try { try {
const result = await register(name, email, password); const result = await register(username, displayName, email, password);
if (result?.needsVerification) { if (result?.needsVerification) {
setNeedsVerification(true); setNeedsVerification(true);
toast.success(t('auth.verificationSent')); toast.success(t('auth.verificationSent'));
@@ -87,15 +88,31 @@ export default function Register() {
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label> <label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<div className="relative"> <div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input <input
type="text" type="text"
value={name} value={username}
onChange={e => setName(e.target.value)} onChange={e => setUsername(e.target.value)}
className="input-field pl-11" className="input-field pl-11"
placeholder={t('auth.namePlaceholder')} placeholder={t('auth.usernamePlaceholder')}
required
/>
</div>
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.displayNamePlaceholder')}
required required
/> />
</div> </div>

View File

@@ -574,11 +574,11 @@ export default function RoomDetail() {
{u.avatar_image ? ( {u.avatar_image ? (
<img src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`} alt="" className="w-full h-full object-cover" /> <img src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`} alt="" className="w-full h-full object-cover" />
) : ( ) : (
u.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) (u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
)} )}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.name}</div> <div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-text-s truncate">{u.email}</div> <div className="text-xs text-th-text-s truncate">{u.email}</div>
</div> </div>
</button> </button>
@@ -600,11 +600,11 @@ export default function RoomDetail() {
{u.avatar_image ? ( {u.avatar_image ? (
<img src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`} alt="" className="w-full h-full object-cover" /> <img src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`} alt="" className="w-full h-full object-cover" />
) : ( ) : (
u.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) (u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
)} )}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.name}</div> <div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-text-s truncate">{u.email}</div> <div className="text-xs text-th-text-s truncate">{u.email}</div>
</div> </div>
</div> </div>

View File

@@ -14,6 +14,7 @@ export default function Settings() {
const [profile, setProfile] = useState({ const [profile, setProfile] = useState({
name: user?.name || '', name: user?.name || '',
display_name: user?.display_name || '',
email: user?.email || '', email: user?.email || '',
}); });
const [passwords, setPasswords] = useState({ const [passwords, setPasswords] = useState({
@@ -52,6 +53,7 @@ export default function Settings() {
try { try {
const res = await api.put('/auth/profile', { const res = await api.put('/auth/profile', {
name: profile.name, name: profile.name,
display_name: profile.display_name,
email: profile.email, email: profile.email,
theme, theme,
language, language,
@@ -190,7 +192,7 @@ export default function Settings() {
className="w-16 h-16 rounded-full flex items-center justify-center text-white text-xl font-bold" className="w-16 h-16 rounded-full flex items-center justify-center text-white text-xl font-bold"
style={{ backgroundColor: user?.avatar_color || '#6366f1' }} style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
> >
{user?.name?.[0]?.toUpperCase() || '?'} {(user?.display_name || user?.name)?.[0]?.toUpperCase() || '?'}
</div> </div>
)} )}
<button <button
@@ -259,7 +261,7 @@ export default function Settings() {
<form onSubmit={handleProfileSave} className="space-y-4"> <form onSubmit={handleProfileSave} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label> <label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<div className="relative"> <div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input <input
@@ -270,6 +272,19 @@ export default function Settings() {
required required
/> />
</div> </div>
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={profile.display_name}
onChange={e => setProfile({ ...profile, display_name: e.target.value })}
className="input-field pl-11"
/>
</div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label> <label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>