GOG Login: Nahtloser Auth-Flow wie Steam (kein Code-Paste noetig)
- Electron: Fängt GOG Redirect automatisch ab (will-redirect Interceptor) - Code-Exchange passiert im Hintergrund, User sieht nur Erfolgs-Popup - GOG Auth-URLs (auth.gog.com, login.gog.com, embed.gog.com) in Popup erlaubt - Server: GET /gog/login Redirect + POST /gog/exchange Endpoint - Browser-Fallback: Code-Paste Dialog falls nicht in Electron - gog.ts: Feste redirect_uri (embed.gog.com/on_login_success) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
af2448a5bd
commit
3e8febb851
5 changed files with 281 additions and 34 deletions
|
|
@ -222,6 +222,53 @@ document.getElementById('cancelBtn').addEventListener('click', () => {
|
||||||
return { action: 'allow' };
|
return { action: 'allow' };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── GOG OAuth: intercept the redirect in child windows ──
|
||||||
|
mainWindow.webContents.on('did-create-window', (childWindow) => {
|
||||||
|
childWindow.webContents.on('will-redirect', async (event, url) => {
|
||||||
|
// GOG redirects to embed.gog.com/on_login_success?code=XXX after auth
|
||||||
|
if (url.includes('on_login_success') && url.includes('code=')) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const code = parsed.searchParams.get('code') || '';
|
||||||
|
const state = parsed.searchParams.get('state') || '';
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
// Exchange the code via our server
|
||||||
|
const resp = await fetch(`${HUB_URL}/api/game-library/gog/exchange`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code, linkTo: state }),
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
|
||||||
|
if (resp.ok && result.ok) {
|
||||||
|
childWindow.loadURL(`data:text/html,${encodeURIComponent(`<!DOCTYPE html><html><head><style>body{background:#1a1a2e;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}div{text-align:center}h2{color:#a855f7}</style></head><body><div><h2>GOG verbunden!</h2><p>${result.profileName}: ${result.gameCount} Spiele geladen.</p></div></body></html>`)}`);
|
||||||
|
// Notify renderer to refresh profiles
|
||||||
|
mainWindow.webContents.executeJavaScript('window.dispatchEvent(new Event("gog-connected"))');
|
||||||
|
setTimeout(() => { try { childWindow.close(); } catch {} }, 2500);
|
||||||
|
} else {
|
||||||
|
childWindow.loadURL(`data:text/html,${encodeURIComponent(`<!DOCTYPE html><html><head><style>body{background:#1a1a2e;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}div{text-align:center}h2{color:#e74c3c}</style></head><body><div><h2>Fehler</h2><p>${result.error || 'Unbekannter Fehler'}</p></div></body></html>`)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[GOG] Exchange error:', err);
|
||||||
|
childWindow.loadURL(`data:text/html,${encodeURIComponent('<!DOCTYPE html><html><head><style>body{background:#1a1a2e;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}div{text-align:center}h2{color:#e74c3c}</style></head><body><div><h2>Fehler</h2><p>GOG-Verbindung fehlgeschlagen.</p></div></body></html>')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow child windows to navigate to GOG auth (don't open in external browser)
|
||||||
|
childWindow.webContents.on('will-navigate', (event, url) => {
|
||||||
|
if (url.startsWith('https://auth.gog.com') || url.startsWith('https://login.gog.com') ||
|
||||||
|
url.startsWith('https://embed.gog.com') || url.startsWith(HUB_URL)) {
|
||||||
|
// Allow navigation within popup for GOG auth flow
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Handle navigation to external URLs
|
// Handle navigation to external URLs
|
||||||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||||
if (!url.startsWith(HUB_URL)) {
|
if (!url.startsWith(HUB_URL)) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const GOG_CLIENT_SECRET =
|
||||||
const GOG_AUTH_URL = 'https://auth.gog.com/auth';
|
const GOG_AUTH_URL = 'https://auth.gog.com/auth';
|
||||||
const GOG_TOKEN_URL = 'https://auth.gog.com/token';
|
const GOG_TOKEN_URL = 'https://auth.gog.com/token';
|
||||||
const GOG_EMBED_URL = 'https://embed.gog.com';
|
const GOG_EMBED_URL = 'https://embed.gog.com';
|
||||||
|
const GOG_REDIRECT_URI = 'https://embed.gog.com/on_login_success?origin=client';
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -37,13 +38,13 @@ export interface GogGame {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the GOG OAuth authorization URL that the user should visit to grant
|
* Build the GOG OAuth authorization URL that the user should visit to grant
|
||||||
* access. After approval, GOG will redirect to `redirectUri` with a `code`
|
* access. After approval, GOG redirects to `embed.gog.com/on_login_success`
|
||||||
* query parameter.
|
* with a `code` query parameter that the user copies back into the app.
|
||||||
*/
|
*/
|
||||||
export function getGogAuthUrl(redirectUri: string): string {
|
export function getGogAuthUrl(): string {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: GOG_CLIENT_ID,
|
client_id: GOG_CLIENT_ID,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: GOG_REDIRECT_URI,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
layout: 'client2',
|
layout: 'client2',
|
||||||
});
|
});
|
||||||
|
|
@ -53,10 +54,10 @@ export function getGogAuthUrl(redirectUri: string): string {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange an authorization code for GOG access + refresh tokens.
|
* Exchange an authorization code for GOG access + refresh tokens.
|
||||||
|
* Uses the fixed GOG_REDIRECT_URI that matches the OAuth registration.
|
||||||
*/
|
*/
|
||||||
export async function exchangeGogCode(
|
export async function exchangeGogCode(
|
||||||
code: string,
|
code: string,
|
||||||
redirectUri: string,
|
|
||||||
): Promise<GogTokens> {
|
): Promise<GogTokens> {
|
||||||
console.log('[GOG] Exchanging authorization code for tokens...');
|
console.log('[GOG] Exchanging authorization code for tokens...');
|
||||||
|
|
||||||
|
|
@ -65,7 +66,7 @@ export async function exchangeGogCode(
|
||||||
client_secret: GOG_CLIENT_SECRET,
|
client_secret: GOG_CLIENT_SECRET,
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: GOG_REDIRECT_URI,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await fetch(`${GOG_TOKEN_URL}?${params.toString()}`);
|
const res = await fetch(`${GOG_TOKEN_URL}?${params.toString()}`);
|
||||||
|
|
|
||||||
|
|
@ -613,32 +613,29 @@ const gameLibraryPlugin: Plugin = {
|
||||||
res.json({ profiles });
|
res.json({ profiles });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GOG Login ──
|
// ── GOG Login (redirect to GOG auth page) ──
|
||||||
app.get('/api/game-library/gog/login', (req, res) => {
|
app.get('/api/game-library/gog/login', (req, res) => {
|
||||||
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
|
const linkTo = req.query.linkTo ? String(req.query.linkTo) : '';
|
||||||
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';
|
// Pass linkTo as OAuth state param — GOG passes it back to redirect_uri
|
||||||
const realm = `${proto}://${host}`;
|
let authUrl = getGogAuthUrl();
|
||||||
const redirectUri = `${realm}/api/game-library/gog/callback`;
|
if (linkTo) authUrl += `&state=${encodeURIComponent(linkTo)}`;
|
||||||
const linkTo = req.query.linkTo ? `&state=${req.query.linkTo}` : '';
|
res.redirect(authUrl);
|
||||||
res.redirect(getGogAuthUrl(redirectUri) + linkTo);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GOG Callback ──
|
// ── GOG Code Exchange ──
|
||||||
app.get('/api/game-library/gog/callback', async (req, res) => {
|
// User pastes the code from the GOG redirect URL. We exchange it for
|
||||||
|
// tokens and fetch the game library.
|
||||||
|
app.post('/api/game-library/gog/exchange', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const code = String(req.query.code || '');
|
const code = String(req.body?.code || '');
|
||||||
const linkToProfileId = String(req.query.state || '');
|
const linkToProfileId = String(req.body?.linkTo || '');
|
||||||
if (!code) {
|
if (!code) {
|
||||||
res.status(400).send(errorPage('GOG-Authentifizierung fehlgeschlagen', 'Kein Authorization-Code erhalten.'));
|
res.status(400).json({ error: 'Kein Authorization-Code angegeben.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
|
|
||||||
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';
|
|
||||||
const redirectUri = `${proto}://${host}/api/game-library/gog/callback`;
|
|
||||||
|
|
||||||
// Exchange code for tokens
|
// Exchange code for tokens
|
||||||
const tokens = await exchangeGogCode(code, redirectUri);
|
const tokens = await exchangeGogCode(code);
|
||||||
|
|
||||||
// Fetch user info + games
|
// Fetch user info + games
|
||||||
const [userInfo, games] = await Promise.all([
|
const [userInfo, games] = await Promise.all([
|
||||||
|
|
@ -689,10 +686,10 @@ const gameLibraryPlugin: Plugin = {
|
||||||
|
|
||||||
console.log(`[GameLibrary] GOG verknuepft: ${profileName} (${userInfo.userId}) - ${games.length} Spiele`);
|
console.log(`[GameLibrary] GOG verknuepft: ${profileName} (${userInfo.userId}) - ${games.length} Spiele`);
|
||||||
|
|
||||||
res.send(`<!DOCTYPE html><html><head><title>GOG verbunden</title><style>body{background:#1a1a2e;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}div{text-align:center}h2{color:#a855f7}</style></head><body><div><h2>GOG verbunden!</h2><p>${profileName}: ${games.length} Spiele geladen.</p><p>Du kannst dieses Fenster schliessen.</p><script>setTimeout(()=>window.close(),3000)</script></div></body></html>`);
|
res.json({ ok: true, username: userInfo.username, gameCount: games.length, profileName });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[GameLibrary] GOG Callback error:', err);
|
console.error('[GameLibrary] GOG exchange error:', err);
|
||||||
res.status(500).send(errorPage('GOG-Fehler', 'Ein unerwarteter Fehler ist aufgetreten.'));
|
res.status(500).json({ error: 'GOG-Authentifizierung fehlgeschlagen.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -137,17 +137,73 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
||||||
}, [fetchProfiles]);
|
}, [fetchProfiles]);
|
||||||
|
|
||||||
// ── GOG login ──
|
// ── GOG login ──
|
||||||
|
const isElectron = navigator.userAgent.includes('GamingHubDesktop');
|
||||||
|
|
||||||
|
// Code-paste dialog state (browser fallback only)
|
||||||
|
const [gogDialogOpen, setGogDialogOpen] = useState(false);
|
||||||
|
const [gogCode, setGogCode] = useState('');
|
||||||
|
const [gogStatus, setGogStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||||
|
const [gogStatusMsg, setGogStatusMsg] = useState('');
|
||||||
|
|
||||||
const connectGog = useCallback(() => {
|
const connectGog = useCallback(() => {
|
||||||
// If viewing a profile, pass linkTo param so GOG gets linked to it
|
|
||||||
const linkParam = selectedProfile ? `?linkTo=${selectedProfile}` : '';
|
const linkParam = selectedProfile ? `?linkTo=${selectedProfile}` : '';
|
||||||
const w = window.open(`/api/game-library/gog/login${linkParam}`, '_blank', 'width=800,height=600');
|
|
||||||
const interval = setInterval(() => {
|
if (isElectron) {
|
||||||
if (w && w.closed) {
|
// Electron: Popup opens → GOG auth → Electron intercepts redirect → done
|
||||||
clearInterval(interval);
|
const w = window.open(`/api/game-library/gog/login${linkParam}`, '_blank', 'width=800,height=700');
|
||||||
setTimeout(fetchProfiles, 1500);
|
const interval = setInterval(() => {
|
||||||
|
if (w && w.closed) {
|
||||||
|
clearInterval(interval);
|
||||||
|
setTimeout(fetchProfiles, 1000);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
// Browser: Open GOG auth in popup, show code-paste dialog
|
||||||
|
window.open(`/api/game-library/gog/login${linkParam}`, '_blank', 'width=800,height=700');
|
||||||
|
setGogCode('');
|
||||||
|
setGogStatus('idle');
|
||||||
|
setGogStatusMsg('');
|
||||||
|
setGogDialogOpen(true);
|
||||||
|
}
|
||||||
|
}, [fetchProfiles, selectedProfile, isElectron]);
|
||||||
|
|
||||||
|
const submitGogCode = useCallback(async () => {
|
||||||
|
let code = gogCode.trim();
|
||||||
|
// Accept full URL or just the code
|
||||||
|
const codeMatch = code.match(/[?&]code=([^&]+)/);
|
||||||
|
if (codeMatch) code = codeMatch[1];
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
setGogStatus('loading');
|
||||||
|
setGogStatusMsg('Verbinde mit GOG...');
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/game-library/gog/exchange', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code, linkTo: selectedProfile || '' }),
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
if (resp.ok && result.ok) {
|
||||||
|
setGogStatus('success');
|
||||||
|
setGogStatusMsg(`${result.profileName}: ${result.gameCount} Spiele geladen!`);
|
||||||
|
fetchProfiles();
|
||||||
|
setTimeout(() => setGogDialogOpen(false), 2000);
|
||||||
|
} else {
|
||||||
|
setGogStatus('error');
|
||||||
|
setGogStatusMsg(result.error || 'Unbekannter Fehler');
|
||||||
}
|
}
|
||||||
}, 500);
|
} catch {
|
||||||
}, [fetchProfiles, selectedProfile]);
|
setGogStatus('error');
|
||||||
|
setGogStatusMsg('Verbindung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
}, [gogCode, selectedProfile, fetchProfiles]);
|
||||||
|
|
||||||
|
// ── Listen for GOG-connected event from Electron main process ──
|
||||||
|
useEffect(() => {
|
||||||
|
const onGogConnected = () => fetchProfiles();
|
||||||
|
window.addEventListener('gog-connected', onGogConnected);
|
||||||
|
return () => window.removeEventListener('gog-connected', onGogConnected);
|
||||||
|
}, [fetchProfiles]);
|
||||||
|
|
||||||
// ── Refetch on window focus (after login redirect) ──
|
// ── Refetch on window focus (after login redirect) ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -797,6 +853,42 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* ── GOG Code Dialog (browser fallback only) ── */}
|
||||||
|
{gogDialogOpen && (
|
||||||
|
<div className="gl-dialog-overlay" onClick={() => setGogDialogOpen(false)}>
|
||||||
|
<div className="gl-dialog" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3>🟣 GOG verbinden</h3>
|
||||||
|
<p className="gl-dialog-hint">
|
||||||
|
Nach dem GOG-Login wirst du auf eine Seite weitergeleitet.
|
||||||
|
Kopiere die <strong>komplette URL</strong> aus der Adressleiste und füge sie hier ein:
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="gl-dialog-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://embed.gog.com/on_login_success?code=..."
|
||||||
|
value={gogCode}
|
||||||
|
onChange={e => setGogCode(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') submitGogCode(); }}
|
||||||
|
disabled={gogStatus === 'loading' || gogStatus === 'success'}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{gogStatusMsg && (
|
||||||
|
<p className={`gl-dialog-status ${gogStatus}`}>{gogStatusMsg}</p>
|
||||||
|
)}
|
||||||
|
<div className="gl-dialog-actions">
|
||||||
|
<button onClick={() => setGogDialogOpen(false)} className="gl-dialog-cancel">Abbrechen</button>
|
||||||
|
<button
|
||||||
|
onClick={submitGogCode}
|
||||||
|
className="gl-dialog-submit"
|
||||||
|
disabled={!gogCode.trim() || gogStatus === 'loading' || gogStatus === 'success'}
|
||||||
|
>
|
||||||
|
{gogStatus === 'loading' ? 'Verbinde...' : 'Verbinden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -737,3 +737,113 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── GOG Code Dialog (browser fallback) ── */
|
||||||
|
.gl-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog {
|
||||||
|
background: #2a2a3e;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog-input:focus {
|
||||||
|
border-color: #a855f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog-status {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog-status.loading {
|
||||||
|
color: #a855f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog-status.success {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog-status.error {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog-cancel {
|
||||||
|
padding: 8px 18px;
|
||||||
|
background: #3a3a4e;
|
||||||
|
color: #ccc;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog-cancel:hover {
|
||||||
|
background: #4a4a5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog-submit {
|
||||||
|
padding: 8px 18px;
|
||||||
|
background: #a855f7;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog-submit:hover:not(:disabled) {
|
||||||
|
background: #9333ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-dialog-submit:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue