chore: prepare standalone public repo
- Remove Mattermost integration (OAuth, channel bridge, file sync watcher, frontend import modal). RefBoard now ships as a self-contained app. - Replace SSO Login screen with email/password form (+ optional register link gated by ALLOW_SELF_REGISTRATION). - Add SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD env-var bootstrap so a fresh install ships with an admin account on first boot (idempotent). - ALLOW_SELF_REGISTRATION flag (default false) gates POST /api/auth/register. First user can always register (auto-promoted to admin). - Drop mattermost_id and mm_file_id columns + board_channel_links table from the schema; remove related db helpers and exports. - Add MIT LICENSE, comprehensive README, .env.example, docker-compose.yml (bundles MinIO so one command boots a working stack). - Expand .gitignore for typical Node + Docker dev artefacts.
This commit is contained in:
@@ -1,321 +0,0 @@
|
||||
/**
|
||||
* MattermostImport — modal dialog for importing media from Mattermost.
|
||||
*
|
||||
* Allows pulling images from a Mattermost thread/channel URL and managing
|
||||
* linked channels for auto-sync.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
interface MattermostImportProps {
|
||||
boardId: string;
|
||||
onClose: () => void;
|
||||
onMediaArrived?: (assets: { assetKey: string; w: number; h: number }[]) => void;
|
||||
}
|
||||
|
||||
interface LinkedChannel {
|
||||
id: string;
|
||||
channelName: string;
|
||||
channelId: string;
|
||||
linkedAt: string;
|
||||
}
|
||||
|
||||
export default function MattermostImport({ boardId, onClose, onMediaArrived }: MattermostImportProps) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [pulling, setPulling] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [linkedChannels, setLinkedChannels] = useState<LinkedChannel[]>([]);
|
||||
const [loadingChannels, setLoadingChannels] = useState(false);
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [linking, setLinking] = useState(false);
|
||||
|
||||
// Load linked channels on mount
|
||||
const loadLinkedChannels = useCallback(async () => {
|
||||
setLoadingChannels(true);
|
||||
try {
|
||||
const res = await api.get(`/api/boards/${boardId}/mm-links`);
|
||||
setLinkedChannels(res.data?.links || []);
|
||||
} catch {
|
||||
// Endpoint may not exist yet — silently ignore
|
||||
setLinkedChannels([]);
|
||||
} finally {
|
||||
setLoadingChannels(false);
|
||||
}
|
||||
}, [boardId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadLinkedChannels();
|
||||
}, [loadLinkedChannels]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Pull images from URL
|
||||
const handlePull = async () => {
|
||||
if (!url.trim()) return;
|
||||
setPulling(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const res = await api.post(`/api/boards/${boardId}/mm-pull`, { url: url.trim() });
|
||||
const assets = res.data?.assets || [];
|
||||
const count = assets.length;
|
||||
setSuccess(`Pulled ${count} image${count !== 1 ? 's' : ''}`);
|
||||
setUrl('');
|
||||
|
||||
if (count > 0 && onMediaArrived) {
|
||||
onMediaArrived(assets);
|
||||
}
|
||||
|
||||
// Auto-close after brief delay on success
|
||||
if (count > 0) {
|
||||
setTimeout(onClose, 800);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to pull images');
|
||||
} finally {
|
||||
setPulling(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Link a channel
|
||||
const handleLink = async () => {
|
||||
if (!linkUrl.trim()) return;
|
||||
setLinking(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await api.post(`/api/boards/${boardId}/mm-links`, { url: linkUrl.trim() });
|
||||
setLinkUrl('');
|
||||
loadLinkedChannels();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to link channel');
|
||||
} finally {
|
||||
setLinking(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Unlink a channel
|
||||
const handleUnlink = async (linkId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/boards/${boardId}/mm-links/${linkId}`);
|
||||
loadLinkedChannels();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to unlink channel');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 2000,
|
||||
background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(6px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: '#141414', border: '1px solid #222', borderRadius: '16px',
|
||||
width: '100%', maxWidth: '480px', maxHeight: '85vh',
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.6)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '20px 24px 16px', borderBottom: '1px solid #1e1e1e', flexShrink: 0,
|
||||
}}>
|
||||
<h2 style={{
|
||||
margin: 0, fontSize: '16px', fontWeight: 600,
|
||||
color: '#e0e0e0', letterSpacing: '-0.3px',
|
||||
}}>
|
||||
Import from Mattermost
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #333', borderRadius: '6px',
|
||||
color: '#888', padding: '4px 12px', cursor: 'pointer', fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px 24px' }}>
|
||||
{/* Error / Success */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '8px 12px', background: 'rgba(255,80,80,0.1)',
|
||||
border: '1px solid rgba(255,80,80,0.2)', borderRadius: '8px',
|
||||
color: '#ff6b6b', fontSize: '12px', marginBottom: '12px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div style={{
|
||||
padding: '8px 12px', background: 'rgba(74,222,128,0.1)',
|
||||
border: '1px solid rgba(74,222,128,0.2)', borderRadius: '8px',
|
||||
color: '#4ade80', fontSize: '12px', marginBottom: '12px',
|
||||
}}>
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pull images section */}
|
||||
<SectionLabel text="Pull Images from Thread" />
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handlePull(); }}
|
||||
placeholder="https://chat.metalfinger.xyz/team/pl/..."
|
||||
style={{
|
||||
flex: 1, padding: '8px 12px', background: '#1a1a1a',
|
||||
border: '1px solid #333', borderRadius: '8px',
|
||||
color: '#e0e0e0', fontSize: '13px', outline: 'none',
|
||||
}}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = '#333'; }}
|
||||
/>
|
||||
<button
|
||||
onClick={handlePull}
|
||||
disabled={pulling || !url.trim()}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: pulling ? '#333' : 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
||||
border: 'none', borderRadius: '8px',
|
||||
color: '#fff', fontSize: '12px', fontWeight: 600,
|
||||
cursor: pulling ? 'wait' : 'pointer',
|
||||
opacity: !url.trim() ? 0.5 : 1,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{pulling ? 'Pulling...' : 'Pull Images'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Linked channels section */}
|
||||
<SectionLabel text="Linked Channels" />
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
{loadingChannels ? (
|
||||
<div style={{ fontSize: '12px', color: '#555', padding: '8px 0' }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : linkedChannels.length === 0 ? (
|
||||
<div style={{ fontSize: '12px', color: '#555', padding: '8px 0' }}>
|
||||
No linked channels. Link one below for auto-sync.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{linkedChannels.map((ch) => (
|
||||
<div
|
||||
key={ch.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 12px', background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a', borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: '#ccc', fontWeight: 500 }}>
|
||||
{ch.channelName || ch.channelId}
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: '#555', marginTop: '2px' }}>
|
||||
Linked {new Date(ch.linkedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUnlink(ch.id)}
|
||||
style={{
|
||||
padding: '4px 10px', background: 'transparent',
|
||||
border: '1px solid #333', borderRadius: '6px',
|
||||
color: '#888', fontSize: '11px', cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ff6b6b';
|
||||
e.currentTarget.style.color = '#ff6b6b';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
e.currentTarget.style.color = '#888';
|
||||
}}
|
||||
>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Link new channel */}
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleLink(); }}
|
||||
placeholder="Channel URL to link..."
|
||||
style={{
|
||||
flex: 1, padding: '8px 12px', background: '#1a1a1a',
|
||||
border: '1px solid #333', borderRadius: '8px',
|
||||
color: '#e0e0e0', fontSize: '13px', outline: 'none',
|
||||
}}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = '#333'; }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleLink}
|
||||
disabled={linking || !linkUrl.trim()}
|
||||
style={{
|
||||
padding: '8px 16px', background: '#1a1a1a',
|
||||
border: '1px solid #333', borderRadius: '8px',
|
||||
color: '#ccc', fontSize: '12px', fontWeight: 500,
|
||||
cursor: linking ? 'wait' : 'pointer',
|
||||
opacity: !linkUrl.trim() ? 0.5 : 1,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (linkUrl.trim()) {
|
||||
e.currentTarget.style.borderColor = '#4a9eff';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
}}
|
||||
>
|
||||
{linking ? 'Linking...' : 'Link Channel'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ text }: { text: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: '10px', fontWeight: 700, color: '#4a9eff', letterSpacing: '0.8px',
|
||||
textTransform: 'uppercase', padding: '0 0 8px', marginBottom: '4px',
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,6 @@ interface ToolbarProps {
|
||||
onToggleLayers?: () => void;
|
||||
showLayers?: boolean;
|
||||
onToggleHelp?: () => void;
|
||||
onMmImport?: () => void;
|
||||
onExport?: () => void;
|
||||
onRefreshPreview?: () => void;
|
||||
previewRefreshing?: boolean;
|
||||
@@ -165,7 +164,6 @@ export default function Toolbar({
|
||||
onToggleLayers,
|
||||
showLayers,
|
||||
onToggleHelp,
|
||||
onMmImport,
|
||||
onExport,
|
||||
onRefreshPreview,
|
||||
previewRefreshing,
|
||||
@@ -330,16 +328,6 @@ export default function Toolbar({
|
||||
</ActionBtn>
|
||||
)}
|
||||
|
||||
{/* Mattermost import */}
|
||||
{onMmImport && (
|
||||
<ActionBtn onClick={onMmImport} title="Import from Mattermost">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||
<path d="M2 10V4a2 2 0 012-2h6a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2z" />
|
||||
<path d="M5 7h4M7 5v4" strokeLinecap="round" />
|
||||
</svg>
|
||||
</ActionBtn>
|
||||
)}
|
||||
|
||||
{/* Export */}
|
||||
{onExport && (
|
||||
<ActionBtn onClick={onExport} title="Export as image (Ctrl+Shift+E)">
|
||||
|
||||
@@ -24,7 +24,6 @@ import TextFormatToolbar from '../components/TextFormatToolbar';
|
||||
import { getStickyWidthForSize } from '../canvas/stickyPresets';
|
||||
import VideoControls from '../components/VideoControls';
|
||||
import ShortcutsHelp from '../components/ShortcutsHelp';
|
||||
import MattermostImport from '../components/MattermostImport';
|
||||
import Minimap from '../components/Minimap';
|
||||
import UploadPanel from '../components/UploadPanel';
|
||||
import ExportDialog from '../components/ExportDialog';
|
||||
@@ -118,7 +117,6 @@ export default function Editor({ isPublicView }: EditorProps) {
|
||||
const [showLayers, setShowLayers] = useState(false);
|
||||
const [showGrid, setShowGrid] = useState(true);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [showMmImport, setShowMmImport] = useState(false);
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
const [refreshingPreview, setRefreshingPreview] = useState(false);
|
||||
const [reviewMode, setReviewMode] = useState(false);
|
||||
@@ -1093,7 +1091,6 @@ export default function Editor({ isPublicView }: EditorProps) {
|
||||
onToggleLayers={() => setShowLayers((v) => !v)}
|
||||
showLayers={showLayers}
|
||||
onToggleHelp={() => setShowHelp((v) => !v)}
|
||||
onMmImport={() => setShowMmImport(true)}
|
||||
onExport={() => setShowExport(true)}
|
||||
onRefreshPreview={readOnly || isPublicView ? undefined : handleRefreshPreview}
|
||||
previewRefreshing={refreshingPreview}
|
||||
@@ -1701,19 +1698,6 @@ export default function Editor({ isPublicView }: EditorProps) {
|
||||
<ShortcutsHelp shortcuts={shortcutDefs} onClose={() => setShowHelp(false)} />
|
||||
)}
|
||||
|
||||
{/* Mattermost import modal */}
|
||||
{showMmImport && resolvedBoardId && (
|
||||
<MattermostImport
|
||||
boardId={resolvedBoardId}
|
||||
onClose={() => setShowMmImport(false)}
|
||||
onMediaArrived={(assets) => {
|
||||
if (inboxZoneRef.current) {
|
||||
inboxZoneRef.current.addMedia(assets);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Markdown card portals (read-only previews on canvas) */}
|
||||
{mdCardIds.map(id => {
|
||||
const mountPoint = mdOverlay?.getMountPoint(id);
|
||||
|
||||
+111
-58
@@ -1,48 +1,55 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth';
|
||||
import api from '../api';
|
||||
|
||||
const ALLOW_REGISTER = (import.meta.env.VITE_ALLOW_SELF_REGISTRATION || '').toLowerCase() === 'true';
|
||||
|
||||
export default function Login() {
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { login, user } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Handle OAuth callback — token + user in URL params
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token');
|
||||
const userParam = searchParams.get('user');
|
||||
const errorParam = searchParams.get('error');
|
||||
|
||||
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 });
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (user) navigate('/', { replace: true });
|
||||
}, [user, navigate]);
|
||||
|
||||
function handleMattermostLogin() {
|
||||
window.location.href = '/api/auth/mattermost';
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const path = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
||||
const body = mode === 'login'
|
||||
? { email, password }
|
||||
: { email, username, displayName: displayName || username, password };
|
||||
const res = await api.post(path, body);
|
||||
if (res.data?.token && res.data?.user) {
|
||||
login(res.data.token, res.data.user);
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
setError('Unexpected response from server.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || 'Authentication failed.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '12px 14px', marginBottom: '12px',
|
||||
background: '#0d0d0d', color: '#f0f0f0',
|
||||
border: '1px solid #2a2a2a', borderRadius: '8px',
|
||||
fontSize: '14px', boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -50,12 +57,11 @@ export default function Login() {
|
||||
backgroundImage: 'radial-gradient(ellipse at 50% 0%, rgba(74,158,255,0.08) 0%, transparent 60%)',
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#161616', borderRadius: '16px', padding: '48px 40px',
|
||||
background: '#161616', borderRadius: '16px', padding: '40px 36px',
|
||||
width: '100%', maxWidth: '400px',
|
||||
border: '1px solid #222', boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '28px' }}>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '48px', height: '48px', borderRadius: '12px',
|
||||
@@ -72,39 +78,86 @@ export default function Login() {
|
||||
<h1 style={{ margin: '0 0 4px', fontSize: '24px', fontWeight: 700, color: '#f0f0f0', letterSpacing: '-0.5px' }}>
|
||||
RefBoard
|
||||
</h1>
|
||||
<p style={{ margin: 0, fontSize: '13px', color: '#555', letterSpacing: '0.2px' }}>
|
||||
Sign in with your team account
|
||||
<p style={{ margin: 0, fontSize: '13px', color: '#666' }}>
|
||||
{mode === 'login' ? 'Sign in to continue' : 'Create your account'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: 'rgba(255,107,107,0.08)', border: '1px solid rgba(255,107,107,0.15)',
|
||||
color: '#ff8a8a', padding: '10px 14px', borderRadius: '8px', marginBottom: '20px',
|
||||
color: '#ff8a8a', padding: '10px 14px', borderRadius: '8px', marginBottom: '16px',
|
||||
fontSize: '13px', lineHeight: '1.4',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleMattermostLogin}
|
||||
style={{
|
||||
width: '100%', padding: '14px', marginBottom: '0',
|
||||
background: '#386fe5', color: '#fff', border: 'none', borderRadius: '8px',
|
||||
fontSize: '15px', fontWeight: 600, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '10px',
|
||||
transition: 'background 0.2s',
|
||||
boxShadow: '0 2px 12px rgba(56,111,229,0.3)',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#2f5fc4')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = '#386fe5')}
|
||||
>
|
||||
<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>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
{mode === 'register' && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Display name (optional)"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%', padding: '14px', marginTop: '4px',
|
||||
background: loading ? '#2a4f9a' : '#386fe5', color: '#fff',
|
||||
border: 'none', borderRadius: '8px',
|
||||
fontSize: '15px', fontWeight: 600,
|
||||
cursor: loading ? 'wait' : 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
boxShadow: '0 2px 12px rgba(56,111,229,0.3)',
|
||||
}}
|
||||
>
|
||||
{loading ? 'Please wait…' : (mode === 'login' ? 'Sign in' : 'Create account')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{ALLOW_REGISTER && (
|
||||
<div style={{ textAlign: 'center', marginTop: '20px', fontSize: '13px', color: '#666' }}>
|
||||
{mode === 'login' ? (
|
||||
<>Need an account? <a href="#" onClick={(e) => { e.preventDefault(); setMode('register'); setError(''); }} style={{ color: '#4a9eff' }}>Register</a></>
|
||||
) : (
|
||||
<>Already have an account? <a href="#" onClick={(e) => { e.preventDefault(); setMode('login'); setError(''); }} style={{ color: '#4a9eff' }}>Sign in</a></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user