Add Discord OAuth login with whitelist for user 1207017997173137481
This commit is contained in:
parent
db1e9a77d3
commit
2abdb02640
@ -8,6 +8,13 @@ export function AuthProvider({ children }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
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');
|
const token = localStorage.getItem('token');
|
||||||
if (token) {
|
if (token) {
|
||||||
auth.me()
|
auth.me()
|
||||||
|
|||||||
@ -141,6 +141,26 @@ body {
|
|||||||
.btn-primary:hover { background: var(--accent-hover); }
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
.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 {
|
.btn-secondary {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { getDiscordAuthUrl } from '../services/api';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
@ -30,6 +31,7 @@ export default function Login() {
|
|||||||
<input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
<input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||||
<button type="submit" className="btn-primary">Sign In</button>
|
<button type="submit" className="btn-primary">Sign In</button>
|
||||||
</form>
|
</form>
|
||||||
|
<a href={getDiscordAuthUrl()} className="btn-discord">Sign in with Discord</a>
|
||||||
<p className="auth-link">Don't have an account? <Link to="/register">Register</Link></p>
|
<p className="auth-link">Don't have an account? <Link to="/register">Register</Link></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001/api';
|
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 = {}) {
|
async function request(endpoint, options = {}) {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
@ -64,6 +65,10 @@ export const fragments = {
|
|||||||
delete: (id) => request(`/fragments/${id}`, { method: 'DELETE' }),
|
delete: (id) => request(`/fragments/${id}`, { method: 'DELETE' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getDiscordAuthUrl(next = '/') {
|
||||||
|
return `${API_ORIGIN}/auth/discord?next=${encodeURIComponent(next)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const ai = {
|
export const ai = {
|
||||||
suggest: (prompt) => request('/ai/suggest', { method: 'POST', body: JSON.stringify({ prompt }) }),
|
suggest: (prompt) => request('/ai/suggest', { method: 'POST', body: JSON.stringify({ prompt }) }),
|
||||||
generateName: (style) => request('/ai/generate-name', { method: 'POST', body: JSON.stringify({ style }) }),
|
generateName: (style) => request('/ai/generate-name', { method: 'POST', body: JSON.stringify({ style }) }),
|
||||||
|
|||||||
22
server/db.js
22
server/db.js
@ -13,9 +13,13 @@ db.exec(`
|
|||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
email TEXT UNIQUE NOT NULL,
|
email TEXT UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
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 (
|
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;
|
export default db;
|
||||||
|
|||||||
@ -1,11 +1,18 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import authRoutes from './routes/auth.js';
|
import authRoutes from './routes/auth.js';
|
||||||
|
import discordRoutes from './routes/discord.js';
|
||||||
import characterRoutes from './routes/characters.js';
|
import characterRoutes from './routes/characters.js';
|
||||||
import lorebookRoutes from './routes/lorebooks.js';
|
import lorebookRoutes from './routes/lorebooks.js';
|
||||||
import fragmentRoutes from './routes/fragments.js';
|
import fragmentRoutes from './routes/fragments.js';
|
||||||
import aiRoutes from './routes/ai.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 app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
@ -13,6 +20,7 @@ app.use(cors());
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/auth', discordRoutes);
|
||||||
app.use('/api/characters', characterRoutes);
|
app.use('/api/characters', characterRoutes);
|
||||||
app.use('/api/lorebooks', lorebookRoutes);
|
app.use('/api/lorebooks', lorebookRoutes);
|
||||||
app.use('/api/fragments', fragmentRoutes);
|
app.use('/api/fragments', fragmentRoutes);
|
||||||
|
|||||||
17
server/package-lock.json
generated
17
server/package-lock.json
generated
@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "character-sandbox-server",
|
"name": "thw-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "character-sandbox-server",
|
"name": "thw-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
@ -452,6 +453,18 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
|
|||||||
@ -41,7 +41,7 @@ router.post('/login', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get('/me', authenticateToken, (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' });
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
res.json({ user });
|
res.json({ user });
|
||||||
});
|
});
|
||||||
|
|||||||
100
server/routes/discord.js
Normal file
100
server/routes/discord.js
Normal file
@ -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;
|
||||||
Loading…
x
Reference in New Issue
Block a user