- Add mattermost_id column to RefBoard users for OAuth linking
- Admin panel shows per-user RefBoard login status (Active vs Not signed in) - Add RefBoard config: REFBOARD_PUBLIC_URL, REFBOARD_INTERNAL_URL, REFBOARD_API_KEY - Update .env.example with RefBoard section
This commit is contained in:
+16
-1
@@ -204,6 +204,12 @@ try {
|
|||||||
db.exec("ALTER TABLE images ADD COLUMN native_width INTEGER");
|
db.exec("ALTER TABLE images ADD COLUMN native_width INTEGER");
|
||||||
db.exec("ALTER TABLE images ADD COLUMN native_height 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
|
// User helpers
|
||||||
@@ -220,6 +226,14 @@ function getUserByUsername(username) {
|
|||||||
return db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(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 }) {
|
function createUser({ id, email, username, passwordHash, displayName, role }) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO users (id, email, username, password_hash, display_name, role)
|
INSERT INTO users (id, email, username, password_hash, display_name, role)
|
||||||
@@ -641,7 +655,8 @@ function deleteComment(commentId) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
db,
|
db,
|
||||||
// Users
|
// Users
|
||||||
getUserByEmail, getUserById, getUserByUsername, createUser,
|
getUserByEmail, getUserById, getUserByUsername, getUserByMattermostId,
|
||||||
|
createUser, updateUserMattermostId,
|
||||||
getAllUsers, updateUserPassword, deactivateUser, getUserCount,
|
getAllUsers, updateUserPassword, deactivateUser, getUserCount,
|
||||||
// Collections
|
// Collections
|
||||||
getCollections, getCollection, getCollectionByShareToken,
|
getCollections, getCollection, getCollectionByShareToken,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -95,6 +95,7 @@ app.get('/api/users/search', (req, res) => {
|
|||||||
|
|
||||||
// ---- API routes ----
|
// ---- API routes ----
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
|
const oauthRoutes = require('./routes/oauth');
|
||||||
const collectionRoutes = require('./routes/collections');
|
const collectionRoutes = require('./routes/collections');
|
||||||
const boardRoutes = require('./routes/boards');
|
const boardRoutes = require('./routes/boards');
|
||||||
const uploadRoutes = require('./routes/upload');
|
const uploadRoutes = require('./routes/upload');
|
||||||
@@ -103,6 +104,7 @@ const mmBridgeRoutes = require('./routes/mattermost-bridge');
|
|||||||
const threadRoutes = require('./routes/threads');
|
const threadRoutes = require('./routes/threads');
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/auth', oauthRoutes);
|
||||||
app.use('/api/collections', collectionRoutes);
|
app.use('/api/collections', collectionRoutes);
|
||||||
app.use('/api/boards', boardRoutes);
|
app.use('/api/boards', boardRoutes);
|
||||||
app.use('/api/upload', uploadRoutes);
|
app.use('/api/upload', uploadRoutes);
|
||||||
|
|||||||
@@ -1,40 +1,47 @@
|
|||||||
import React, { useState, FormEvent } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth';
|
import { useAuth } from '../auth';
|
||||||
import { login as apiLogin, register as apiRegister } from '../api';
|
|
||||||
|
|
||||||
export default function Login() {
|
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 [error, setError] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login } = useAuth();
|
const { login, user } = useAuth();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
// Handle OAuth callback — token + user in URL params
|
||||||
e.preventDefault();
|
useEffect(() => {
|
||||||
setError('');
|
const token = searchParams.get('token');
|
||||||
setSubmitting(true);
|
const userParam = searchParams.get('user');
|
||||||
|
const errorParam = searchParams.get('error');
|
||||||
|
|
||||||
|
if (errorParam) {
|
||||||
|
setError(errorParam);
|
||||||
|
window.history.replaceState({}, '', '/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token && userParam) {
|
||||||
try {
|
try {
|
||||||
if (isRegister) {
|
const userData = JSON.parse(userParam);
|
||||||
const res = await apiRegister(email, username || email.split('@')[0], password, displayName || username || email.split('@')[0]);
|
login(token, userData);
|
||||||
login(res.data.token, res.data.user);
|
|
||||||
} else {
|
|
||||||
const res = await apiLogin(email, password);
|
|
||||||
login(res.data.token, res.data.user);
|
|
||||||
}
|
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
} catch (err: any) {
|
} catch {
|
||||||
const msg = err.response?.data?.error || err.response?.data?.message || 'Something went wrong';
|
setError('Failed to complete login. Please try again.');
|
||||||
setError(msg);
|
window.history.replaceState({}, '', '/login');
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, [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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -66,7 +73,7 @@ export default function Login() {
|
|||||||
RefBoard
|
RefBoard
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ margin: 0, fontSize: '13px', color: '#555', letterSpacing: '0.2px' }}>
|
<p style={{ margin: 0, fontSize: '13px', color: '#555', letterSpacing: '0.2px' }}>
|
||||||
{isRegister ? 'Create your account' : 'Sign in to continue'}
|
Sign in with your team account
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,84 +87,25 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
{isRegister && (
|
|
||||||
<>
|
|
||||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '12px', color: '#666', fontWeight: 500, letterSpacing: '0.3px', textTransform: 'uppercase' }}>
|
|
||||||
Username
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
style={inputStyle}
|
|
||||||
type="text" value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
placeholder="username" required autoComplete="username"
|
|
||||||
/>
|
|
||||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '12px', color: '#666', fontWeight: 500, letterSpacing: '0.3px', textTransform: 'uppercase' }}>
|
|
||||||
Display Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
style={inputStyle}
|
|
||||||
type="text" value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
placeholder="Your name" autoComplete="name"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '12px', color: '#666', fontWeight: 500, letterSpacing: '0.3px', textTransform: 'uppercase' }}>
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
style={inputStyle}
|
|
||||||
type="email" value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="you@example.com" required autoComplete="email"
|
|
||||||
/>
|
|
||||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '12px', color: '#666', fontWeight: 500, letterSpacing: '0.3px', textTransform: 'uppercase' }}>
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
style={inputStyle}
|
|
||||||
type="password" value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="Password" required
|
|
||||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
|
||||||
minLength={6}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="submit" disabled={submitting}
|
onClick={handleMattermostLogin}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '12px', marginTop: '4px',
|
width: '100%', padding: '14px', marginBottom: '0',
|
||||||
background: submitting ? '#333' : 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
background: '#386fe5', color: '#fff', border: 'none', borderRadius: '8px',
|
||||||
color: '#fff', border: 'none', borderRadius: '8px',
|
fontSize: '15px', fontWeight: 600, cursor: 'pointer',
|
||||||
fontSize: '14px', fontWeight: 600, cursor: submitting ? 'default' : 'pointer',
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '10px',
|
||||||
transition: 'opacity 0.2s', opacity: submitting ? 0.6 : 1,
|
transition: 'background 0.2s',
|
||||||
boxShadow: submitting ? 'none' : '0 2px 12px rgba(74,158,255,0.3)',
|
boxShadow: '0 2px 12px rgba(56,111,229,0.3)',
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = '#2f5fc4')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = '#386fe5')}
|
||||||
>
|
>
|
||||||
{submitting ? 'Please wait...' : isRegister ? 'Create Account' : 'Sign In'}
|
<svg width="18" height="18" viewBox="0 0 500 500" fill="currentColor">
|
||||||
|
<path d="M250 0C111.93 0 0 111.93 0 250s111.93 250 250 250 250-111.93 250-250S388.07 0 250 0zm127.55 354.07c-2.93 5.77-9.18 8.85-15.57 8.85-2.68 0-5.4-.59-7.97-1.85l-72.76-35.72c-19.7 14.88-43.19 22.66-67.58 22.66-6.2 0-12.5-.5-18.73-1.53-24.83-4.07-47.17-16.41-63.38-34.95-16.63-19.03-25.78-43.42-25.78-68.68 0-12.07 2.1-23.86 6.15-35.12.58-1.62 1.54-3.07 2.79-4.22L250.07 91.53c3.08-2.62 7.51-2.62 10.59 0l135.28 112c1.56 1.29 2.65 3.04 3.13 5-.04 11.84-2.08 23.56-6.02 34.7l-72.76-35.72c-7.72-3.79-17.03-.59-20.82 7.13-3.79 7.72-.59 17.03 7.13 20.82l72.76 35.72c-9.28 21.84-26.16 39.95-47.98 50.72l.01-.01 72.76 35.72c7.72 3.79 10.92 13.1 7.13 20.82l.27-.36z"/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Mattermost
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
|
|
||||||
<div style={{ marginTop: '20px', textAlign: 'center', fontSize: '13px', color: '#555' }}>
|
|
||||||
{isRegister ? 'Already have an account?' : "Don't have an account?"}{' '}
|
|
||||||
<button
|
|
||||||
onClick={() => { setIsRegister(!isRegister); setError(''); }}
|
|
||||||
style={{
|
|
||||||
color: '#4a9eff', cursor: 'pointer', background: 'none',
|
|
||||||
border: 'none', fontSize: '13px', fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isRegister ? 'Sign In' : 'Register'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user