diff --git a/client/src/context/AuthContext.js b/client/src/context/AuthContext.js index f040050..a400a98 100644 --- a/client/src/context/AuthContext.js +++ b/client/src/context/AuthContext.js @@ -8,6 +8,13 @@ export function AuthProvider({ children }) { const [loading, setLoading] = useState(true); useEffect(() => { + const params = new URLSearchParams(window.location.search); + const urlToken = params.get('token'); + if (urlToken) { + localStorage.setItem('token', urlToken); + window.history.replaceState({}, '', window.location.pathname); + } + const token = localStorage.getItem('token'); if (token) { auth.me() diff --git a/client/src/index.css b/client/src/index.css index 958c2e6..d731884 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -141,6 +141,26 @@ body { .btn-primary:hover { background: var(--accent-hover); } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-discord { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 12px; + padding: 10px 16px; + background: #5865F2; + color: #fff; + border: none; + border-radius: var(--radius-sm); + font-size: 14px; + cursor: pointer; + transition: var(--transition); + text-decoration: none; + font-weight: 500; +} + +.btn-discord:hover { background: #4752c4; } + .btn-secondary { display: inline-flex; align-items: center; diff --git a/client/src/pages/Login.js b/client/src/pages/Login.js index 5c493ad..1a0c9f8 100644 --- a/client/src/pages/Login.js +++ b/client/src/pages/Login.js @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; +import { getDiscordAuthUrl } from '../services/api'; export default function Login() { const [username, setUsername] = useState(''); @@ -30,6 +31,7 @@ export default function Login() { setPassword(e.target.value)} required /> + Sign in with Discord
Don't have an account? Register
diff --git a/client/src/services/api.js b/client/src/services/api.js index c1a9289..d070a9e 100644 --- a/client/src/services/api.js +++ b/client/src/services/api.js @@ -1,4 +1,5 @@ const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001/api'; +const API_ORIGIN = API_URL.replace(/\/api$/, ''); async function request(endpoint, options = {}) { const token = localStorage.getItem('token'); @@ -64,6 +65,10 @@ export const fragments = { delete: (id) => request(`/fragments/${id}`, { method: 'DELETE' }), }; +export function getDiscordAuthUrl(next = '/') { + return `${API_ORIGIN}/auth/discord?next=${encodeURIComponent(next)}`; +} + export const ai = { suggest: (prompt) => request('/ai/suggest', { method: 'POST', body: JSON.stringify({ prompt }) }), generateName: (style) => request('/ai/generate-name', { method: 'POST', body: JSON.stringify({ style }) }), diff --git a/server/db.js b/server/db.js index bbb3d6e..9f091a3 100644 --- a/server/db.js +++ b/server/db.js @@ -13,9 +13,13 @@ db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - created_at TEXT DEFAULT (datetime('now')) + email TEXT UNIQUE, + password_hash TEXT, + discord_id TEXT UNIQUE, + global_name TEXT, + avatar TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS characters ( @@ -100,4 +104,16 @@ db.exec(` ); `); +// Migrations: add columns that may not exist yet +const migrations = [ + "ALTER TABLE users ADD COLUMN email TEXT", + "ALTER TABLE users ADD COLUMN discord_id TEXT UNIQUE", + "ALTER TABLE users ADD COLUMN global_name TEXT", + "ALTER TABLE users ADD COLUMN avatar TEXT", + "ALTER TABLE users ADD COLUMN updated_at TEXT DEFAULT (datetime('now'))", +]; +for (const sql of migrations) { + try { db.exec(sql); } catch {} +} + export default db; diff --git a/server/index.js b/server/index.js index ab29a8b..9a5b802 100644 --- a/server/index.js +++ b/server/index.js @@ -1,11 +1,18 @@ +import dotenv from 'dotenv'; +import path from 'path'; +import { fileURLToPath } from 'url'; import express from 'express'; import cors from 'cors'; import authRoutes from './routes/auth.js'; +import discordRoutes from './routes/discord.js'; import characterRoutes from './routes/characters.js'; import lorebookRoutes from './routes/lorebooks.js'; import fragmentRoutes from './routes/fragments.js'; import aiRoutes from './routes/ai.js'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.resolve(__dirname, '../.env') }); + const app = express(); const PORT = process.env.PORT || 3001; @@ -13,6 +20,7 @@ app.use(cors()); app.use(express.json()); app.use('/api/auth', authRoutes); +app.use('/auth', discordRoutes); app.use('/api/characters', characterRoutes); app.use('/api/lorebooks', lorebookRoutes); app.use('/api/fragments', fragmentRoutes); diff --git a/server/package-lock.json b/server/package-lock.json index 0cb3581..46388a2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,16 +1,17 @@ { - "name": "character-sandbox-server", + "name": "thw-server", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "character-sandbox-server", + "name": "thw-server", "version": "1.0.0", "dependencies": { "bcrypt": "^5.1.1", "better-sqlite3": "^12.11.1", "cors": "^2.8.5", + "dotenv": "^17.4.2", "express": "^4.18.2", "jsonwebtoken": "^9.0.2", "pg": "^8.11.3", @@ -452,6 +453,18 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/server/package.json b/server/package.json index deb82f9..8b7c8ba 100644 --- a/server/package.json +++ b/server/package.json @@ -12,6 +12,7 @@ "bcrypt": "^5.1.1", "better-sqlite3": "^12.11.1", "cors": "^2.8.5", + "dotenv": "^17.4.2", "express": "^4.18.2", "jsonwebtoken": "^9.0.2", "pg": "^8.11.3", diff --git a/server/routes/auth.js b/server/routes/auth.js index 28ea142..d5d390f 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -41,7 +41,7 @@ router.post('/login', (req, res) => { }); router.get('/me', authenticateToken, (req, res) => { - const user = db.prepare('SELECT id, username, email, created_at FROM users WHERE id = ?').get(req.userId); + const user = db.prepare('SELECT id, username, email, discord_id, global_name, avatar, created_at FROM users WHERE id = ?').get(req.userId); if (!user) return res.status(404).json({ error: 'User not found' }); res.json({ user }); }); diff --git a/server/routes/discord.js b/server/routes/discord.js new file mode 100644 index 0000000..f3cb148 --- /dev/null +++ b/server/routes/discord.js @@ -0,0 +1,100 @@ +import { Router } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import db from '../db.js'; +import { generateToken } from '../middleware/auth.js'; + +const router = Router(); + +const DISCORD_API = 'https://discord.com/api'; + +const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID; +const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET; +const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI; + +const WHITELIST = new Set([ + '1207017997173137481' +]); + +function getRedirectOrigin() { + return process.env.FRONTEND_URL || 'http://localhost:3000'; +} + +router.get('/discord', (req, res) => { + const next = req.query.next || '/'; + const safeNext = next.startsWith('/') && !next.startsWith('//') ? next : '/'; + const params = new URLSearchParams({ + client_id: DISCORD_CLIENT_ID, + redirect_uri: DISCORD_REDIRECT_URI, + response_type: 'code', + scope: 'identify', + state: safeNext, + }); + res.redirect(`${DISCORD_API}/oauth2/authorize?${params.toString()}`); +}); + +router.get('/discord/callback', async (req, res) => { + const { code, state, error } = req.query; + + if (error) { + return res.redirect(`${getRedirectOrigin()}/login?error=${encodeURIComponent(error)}`); + } + if (!code || !state) { + return res.redirect(`${getRedirectOrigin()}/login?error=missing_code_or_state`); + } + + try { + const tokenRes = await fetch(`${DISCORD_API}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: DISCORD_CLIENT_ID, + client_secret: DISCORD_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: DISCORD_REDIRECT_URI, + }).toString(), + }); + if (!tokenRes.ok) { + const text = await tokenRes.text(); + throw new Error(`Discord token exchange failed: ${tokenRes.status} ${text}`); + } + const token = await tokenRes.json(); + + const userRes = await fetch(`${DISCORD_API}/users/@me`, { + headers: { Authorization: `Bearer ${token.access_token}` }, + }); + if (!userRes.ok) { + const text = await userRes.text(); + throw new Error(`Discord user fetch failed: ${userRes.status} ${text}`); + } + const discordUser = await userRes.json(); + + if (!WHITELIST.has(discordUser.id)) { + return res.redirect(`${getRedirectOrigin()}/login?error=not_allowed`); + } + + const existing = db.prepare('SELECT id FROM users WHERE discord_id = ?').get(discordUser.id); + let userId; + if (existing) { + userId = existing.id; + db.prepare( + "UPDATE users SET username = ?, global_name = ?, avatar = ?, updated_at = datetime('now') WHERE id = ?" + ).run(discordUser.username, discordUser.global_name, discordUser.avatar, userId); + } else { + userId = uuidv4(); + db.prepare( + 'INSERT INTO users (id, username, global_name, avatar, discord_id) VALUES (?, ?, ?, ?, ?)' + ).run(userId, discordUser.username, discordUser.global_name, discordUser.avatar, discordUser.id); + } + + const jwt = generateToken(userId); + + const redirectTo = state.startsWith('/') ? state : '/'; + res.redirect(`${getRedirectOrigin()}${redirectTo}?token=${jwt}`); + } catch (e) { + const msg = e instanceof Error ? e.message : 'unknown error'; + res.redirect(`${getRedirectOrigin()}/login?error=${encodeURIComponent(msg.slice(0, 200))}`); + } +}); + +export default router;