diff --git a/backend/db.js b/backend/db.js index 8d7d109..d74183b 100644 --- a/backend/db.js +++ b/backend/db.js @@ -204,6 +204,12 @@ try { db.exec("ALTER TABLE images ADD COLUMN native_width INTEGER"); db.exec("ALTER TABLE images ADD COLUMN native_height INTEGER"); } +try { + db.prepare("SELECT mattermost_id FROM users LIMIT 0").get(); +} catch { + db.exec("ALTER TABLE users ADD COLUMN mattermost_id TEXT"); + db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_mattermost_id ON users(mattermost_id)"); +} // --------------------- // User helpers @@ -220,6 +226,14 @@ function getUserByUsername(username) { return db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username); } +function getUserByMattermostId(mmId) { + return db.prepare('SELECT * FROM users WHERE mattermost_id = ? AND is_active = 1').get(mmId); +} + +function updateUserMattermostId(userId, mmId) { + db.prepare("UPDATE users SET mattermost_id = ?, updated_at = datetime('now') WHERE id = ?").run(mmId, userId); +} + function createUser({ id, email, username, passwordHash, displayName, role }) { db.prepare(` INSERT INTO users (id, email, username, password_hash, display_name, role) @@ -641,7 +655,8 @@ function deleteComment(commentId) { module.exports = { db, // Users - getUserByEmail, getUserById, getUserByUsername, createUser, + getUserByEmail, getUserById, getUserByUsername, getUserByMattermostId, + createUser, updateUserMattermostId, getAllUsers, updateUserPassword, deactivateUser, getUserCount, // Collections getCollections, getCollection, getCollectionByShareToken, diff --git a/backend/routes/oauth.js b/backend/routes/oauth.js new file mode 100644 index 0000000..87f1ab7 --- /dev/null +++ b/backend/routes/oauth.js @@ -0,0 +1,185 @@ +const { Router } = require('express'); +const { v4: uuidv4 } = require('uuid'); +const crypto = require('crypto'); +const { + getUserByEmail, + getUserByUsername, + createUser, + getUserByMattermostId, + updateUserMattermostId, +} = require('../db'); +const { generateToken } = require('../auth'); + +const router = Router(); + +// OAuth config from env +const CLIENT_ID = process.env.MATTERMOST_OAUTH_CLIENT_ID || ''; +const CLIENT_SECRET = process.env.MATTERMOST_OAUTH_CLIENT_SECRET || ''; +const AUTHORIZE_URL = process.env.MATTERMOST_OAUTH_AUTHORIZE_URL || ''; +const TOKEN_URL = process.env.MATTERMOST_OAUTH_TOKEN_URL || ''; +const USERINFO_URL = process.env.MATTERMOST_OAUTH_USERINFO_URL || ''; +const PUBLIC_URL = process.env.PUBLIC_URL || process.env.REFBOARD_PUBLIC_URL || ''; +const CALLBACK_PATH = '/api/auth/mattermost/callback'; + +// CSRF state store: state -> timestamp (expire after 10 min) +const _pendingStates = new Map(); +const STATE_TTL = 10 * 60 * 1000; + +function _cleanupStates() { + const now = Date.now(); + for (const [state, ts] of _pendingStates) { + if (now - ts > STATE_TTL) _pendingStates.delete(state); + } +} + +function isConfigured() { + return !!(CLIENT_ID && CLIENT_SECRET && AUTHORIZE_URL && TOKEN_URL && USERINFO_URL); +} + +/** + * GET /api/auth/mattermost + * Initiates the OAuth flow — redirects browser to Mattermost authorize page. + */ +router.get('/mattermost', (req, res) => { + if (!isConfigured()) { + return res.status(503).json({ error: 'Mattermost OAuth not configured' }); + } + + _cleanupStates(); + const state = crypto.randomBytes(24).toString('hex'); + _pendingStates.set(state, Date.now()); + + const callbackUrl = `${PUBLIC_URL}${CALLBACK_PATH}`; + const params = new URLSearchParams({ + response_type: 'code', + client_id: CLIENT_ID, + redirect_uri: callbackUrl, + state, + }); + + res.redirect(`${AUTHORIZE_URL}?${params.toString()}`); +}); + +/** + * GET /api/auth/mattermost/callback + * Handles the OAuth callback from Mattermost. + */ +router.get('/mattermost/callback', async (req, res) => { + try { + const { code, state, error: oauthError } = req.query; + + if (oauthError) { + console.error('[oauth] Mattermost returned error:', oauthError); + return res.redirect(`/login?error=${encodeURIComponent('Login was denied')}`); + } + + // Validate CSRF state + if (!state || !_pendingStates.has(state)) { + return res.redirect('/login?error=' + encodeURIComponent('Invalid login session. Please try again.')); + } + _pendingStates.delete(state); + + if (!code) { + return res.redirect('/login?error=' + encodeURIComponent('No authorization code received')); + } + + // Exchange code for access token + const callbackUrl = `${PUBLIC_URL}${CALLBACK_PATH}`; + const tokenResp = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + redirect_uri: callbackUrl, + }).toString(), + }); + + if (!tokenResp.ok) { + const text = await tokenResp.text(); + console.error('[oauth] Token exchange failed:', tokenResp.status, text); + return res.redirect('/login?error=' + encodeURIComponent('Login failed. Please try again.')); + } + + const tokenData = await tokenResp.json(); + const accessToken = tokenData.access_token; + + // Fetch user info from Mattermost + const userResp = await fetch(USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!userResp.ok) { + console.error('[oauth] Userinfo fetch failed:', userResp.status); + return res.redirect('/login?error=' + encodeURIComponent('Failed to get user info')); + } + + const mmUser = await userResp.json(); + const mmId = mmUser.id; + const mmEmail = mmUser.email; + const mmUsername = mmUser.username; + const mmDisplayName = [mmUser.first_name, mmUser.last_name].filter(Boolean).join(' ') + || mmUser.nickname || mmUsername; + + // Try to find existing RefBoard user + let user = getUserByMattermostId(mmId); + + if (!user) { + // Try matching by email + user = getUserByEmail(mmEmail); + if (user) { + // Link existing account + updateUserMattermostId(user.id, mmId); + } + } + + if (!user) { + // Auto-create new user + // Handle username collision + let finalUsername = mmUsername; + const existingUsername = getUserByUsername(finalUsername); + if (existingUsername) { + finalUsername = `${mmUsername}_mm`; + } + + user = createUser({ + id: uuidv4(), + email: mmEmail, + username: finalUsername, + passwordHash: `oauth:mattermost:${crypto.randomBytes(16).toString('hex')}`, + displayName: mmDisplayName, + role: 'member', + }); + + updateUserMattermostId(user.id, mmId); + console.log(`[oauth] Created RefBoard user for MM user ${mmUsername} (${mmEmail})`); + } + + // Generate JWT and redirect to frontend + const jwt = generateToken(user); + const userPayload = encodeURIComponent(JSON.stringify({ + id: user.id, + email: user.email, + username: user.username, + display_name: user.display_name, + role: user.role, + })); + + res.redirect(`/login?token=${jwt}&user=${userPayload}`); + } catch (err) { + console.error('[oauth] Callback error:', err); + res.redirect('/login?error=' + encodeURIComponent('Something went wrong. Please try again.')); + } +}); + +/** + * GET /api/auth/mattermost/status + * Check if Mattermost OAuth is configured (for frontend to show/hide button). + */ +router.get('/mattermost/status', (_req, res) => { + res.json({ enabled: isConfigured() }); +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 07a8405..379010d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -95,6 +95,7 @@ app.get('/api/users/search', (req, res) => { // ---- API routes ---- const authRoutes = require('./routes/auth'); +const oauthRoutes = require('./routes/oauth'); const collectionRoutes = require('./routes/collections'); const boardRoutes = require('./routes/boards'); const uploadRoutes = require('./routes/upload'); @@ -103,6 +104,7 @@ const mmBridgeRoutes = require('./routes/mattermost-bridge'); const threadRoutes = require('./routes/threads'); app.use('/api/auth', authRoutes); +app.use('/api/auth', oauthRoutes); app.use('/api/collections', collectionRoutes); app.use('/api/boards', boardRoutes); app.use('/api/upload', uploadRoutes); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 1b52b3a..f6d9639 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,39 +1,46 @@ -import React, { useState, FormEvent } from 'react'; -import { useNavigate } from 'react-router-dom'; +import React, { useState, useEffect } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../auth'; -import { login as apiLogin, register as apiRegister } from '../api'; export default function Login() { - const [isRegister, setIsRegister] = useState(false); - const [email, setEmail] = useState(''); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [displayName, setDisplayName] = useState(''); const [error, setError] = useState(''); - const [submitting, setSubmitting] = useState(false); const navigate = useNavigate(); - const { login } = useAuth(); + const { login, user } = useAuth(); + const [searchParams] = useSearchParams(); - async function handleSubmit(e: FormEvent) { - e.preventDefault(); - setError(''); - setSubmitting(true); + // Handle OAuth callback — token + user in URL params + useEffect(() => { + const token = searchParams.get('token'); + const userParam = searchParams.get('user'); + const errorParam = searchParams.get('error'); - try { - if (isRegister) { - const res = await apiRegister(email, username || email.split('@')[0], password, displayName || username || email.split('@')[0]); - login(res.data.token, res.data.user); - } else { - const res = await apiLogin(email, password); - login(res.data.token, res.data.user); - } - navigate('/', { replace: true }); - } catch (err: any) { - const msg = err.response?.data?.error || err.response?.data?.message || 'Something went wrong'; - setError(msg); - } finally { - setSubmitting(false); + if (errorParam) { + setError(errorParam); + window.history.replaceState({}, '', '/login'); + return; } + + if (token && userParam) { + try { + const userData = JSON.parse(userParam); + login(token, userData); + navigate('/', { replace: true }); + } catch { + setError('Failed to complete login. Please try again.'); + window.history.replaceState({}, '', '/login'); + } + } + }, [searchParams, login, navigate]); + + // If already logged in, redirect + useEffect(() => { + if (user) { + navigate('/', { replace: true }); + } + }, [user, navigate]); + + function handleMattermostLogin() { + window.location.href = '/api/auth/mattermost'; } return ( @@ -66,7 +73,7 @@ export default function Login() { RefBoard

- {isRegister ? 'Create your account' : 'Sign in to continue'} + Sign in with your team account

@@ -80,84 +87,25 @@ export default function Login() { )} -
- {isRegister && ( - <> - - setUsername(e.target.value)} - placeholder="username" required autoComplete="username" - /> - - setDisplayName(e.target.value)} - placeholder="Your name" autoComplete="name" - /> - - )} - - setEmail(e.target.value)} - placeholder="you@example.com" required autoComplete="email" - /> - - setPassword(e.target.value)} - placeholder="Password" required - autoComplete={isRegister ? 'new-password' : 'current-password'} - minLength={6} - /> - -
- -
- {isRegister ? 'Already have an account?' : "Don't have an account?"}{' '} - -
+ ); } - -const inputStyle: React.CSSProperties = { - width: '100%', padding: '10px 14px', marginBottom: '16px', - background: '#0d0d0d', border: '1px solid #2a2a2a', borderRadius: '8px', - color: '#e0e0e0', fontSize: '14px', outline: 'none', - boxSizing: 'border-box', transition: 'border-color 0.2s', -};