213 lines
7.6 KiB
TypeScript
213 lines
7.6 KiB
TypeScript
import { Router, Response } from 'express';
|
|
import bcrypt from 'bcryptjs';
|
|
import { db } from '../db';
|
|
import { generateToken, authMiddleware, AuthenticatedRequest } from '../authMiddleware';
|
|
import { User, AuthProvider } from '../../types/auth';
|
|
|
|
const router = Router();
|
|
|
|
// --- 1. Local Registration ---
|
|
router.post('/register', (req, res: Response) => {
|
|
try {
|
|
const { email, username, password, name, handicapIndex, homeClub, isPublic } = req.body;
|
|
|
|
if (!email || !username || !password) {
|
|
return res.status(400).json({ error: 'Email, username, and password are required' });
|
|
}
|
|
|
|
if (password.length < 6) {
|
|
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
|
}
|
|
|
|
const existingUser = db.findUserByEmailOrUsername(email) || db.findUserByEmailOrUsername(username);
|
|
if (existingUser) {
|
|
return res.status(409).json({ error: 'An account with that email or username already exists' });
|
|
}
|
|
|
|
const salt = bcrypt.genSaltSync(10);
|
|
const passwordHash = bcrypt.hashSync(password, salt);
|
|
|
|
const newUser: User & { passwordHash: string } = {
|
|
id: `usr_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
|
email: email.toLowerCase().trim(),
|
|
username: username.toLowerCase().trim(),
|
|
name: name?.trim() || username.trim(),
|
|
handicapIndex: typeof handicapIndex === 'number' ? handicapIndex : 15.0,
|
|
homeClub: homeClub?.trim() || 'Local Links Club',
|
|
isPublic: isPublic !== false, // default true for community leaderboards
|
|
provider: 'local',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
passwordHash,
|
|
};
|
|
|
|
const savedUser = db.createUser(newUser);
|
|
const token = generateToken(savedUser);
|
|
|
|
return res.status(201).json({
|
|
token,
|
|
user: savedUser,
|
|
settings: db.getSettings(savedUser.id),
|
|
});
|
|
} catch (error) {
|
|
console.error('Registration error:', error);
|
|
return res.status(500).json({ error: 'Internal registration failure' });
|
|
}
|
|
});
|
|
|
|
// --- 2. Local Login ---
|
|
router.post('/login', (req, res: Response) => {
|
|
try {
|
|
const { identifier, password } = req.body;
|
|
|
|
if (!identifier || !password) {
|
|
return res.status(400).json({ error: 'Email/Username and password are required' });
|
|
}
|
|
|
|
const userWithHash = db.findUserByEmailOrUsername(identifier);
|
|
if (!userWithHash || !userWithHash.passwordHash) {
|
|
return res.status(401).json({ error: 'Invalid credentials. Please verify your email and password.' });
|
|
}
|
|
|
|
const isValid = bcrypt.compareSync(password, userWithHash.passwordHash);
|
|
if (!isValid) {
|
|
return res.status(401).json({ error: 'Invalid credentials. Please verify your email and password.' });
|
|
}
|
|
|
|
const { passwordHash: _, ...safeUser } = userWithHash;
|
|
const token = generateToken(safeUser);
|
|
|
|
return res.json({
|
|
token,
|
|
user: safeUser,
|
|
settings: db.getSettings(safeUser.id),
|
|
});
|
|
} catch (error) {
|
|
console.error('Login error:', error);
|
|
return res.status(500).json({ error: 'Internal login failure' });
|
|
}
|
|
});
|
|
|
|
// --- 3. Get Current User & Profile ---
|
|
router.get('/me', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
|
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
|
const settings = db.getSettings(req.user.id);
|
|
return res.json({
|
|
user: req.user,
|
|
settings,
|
|
});
|
|
});
|
|
|
|
// --- 4. Update Profile & Privacy (Public / Private toggle) ---
|
|
router.put('/profile', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
|
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { name, handicapIndex, homeClub, isPublic, avatarUrl } = req.body;
|
|
const updates: Partial<User> = {};
|
|
|
|
if (typeof name === 'string' && name.trim()) updates.name = name.trim();
|
|
if (typeof handicapIndex === 'number') updates.handicapIndex = Math.max(-10, Math.min(54, handicapIndex));
|
|
if (typeof homeClub === 'string') updates.homeClub = homeClub.trim();
|
|
if (typeof isPublic === 'boolean') updates.isPublic = isPublic;
|
|
if (typeof avatarUrl === 'string') updates.avatarUrl = avatarUrl;
|
|
|
|
const updatedUser = db.updateUser(req.user.id, updates);
|
|
return res.json({ user: updatedUser });
|
|
});
|
|
|
|
// --- 5. User Settings Sync ---
|
|
router.get('/settings', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
|
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
|
return res.json(db.getSettings(req.user.id));
|
|
});
|
|
|
|
router.put('/settings', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
|
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
|
const updated = db.updateSettings(req.user.id, req.body);
|
|
return res.json(updated);
|
|
});
|
|
|
|
// --- 6. OAuth Providers Discovery ---
|
|
router.get('/oauth/providers', (_req, res: Response) => {
|
|
res.json({
|
|
providers: [
|
|
{
|
|
id: 'google',
|
|
name: 'Google',
|
|
icon: 'Google',
|
|
isConfigured: !!process.env.GOOGLE_CLIENT_ID,
|
|
authUrlEndpoint: '/api/auth/oauth/google',
|
|
},
|
|
{
|
|
id: 'authentik',
|
|
name: 'Authentik (OIDC)',
|
|
icon: 'Shield',
|
|
isConfigured: !!process.env.AUTHENTIK_ISSUER_URL,
|
|
issuerUrl: process.env.AUTHENTIK_ISSUER_URL || 'https://authentik.company.local',
|
|
authUrlEndpoint: '/api/auth/oauth/authentik',
|
|
},
|
|
{
|
|
id: 'github',
|
|
name: 'GitHub',
|
|
icon: 'Github',
|
|
isConfigured: !!process.env.GITHUB_CLIENT_ID,
|
|
authUrlEndpoint: '/api/auth/oauth/github',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// --- 7. OAuth / SSO Login (Google, Authentik, GitHub, Demo SSO) ---
|
|
router.post('/oauth/sso-login', (req, res: Response) => {
|
|
try {
|
|
const { provider, email, name, username, avatarUrl, authentikServerUrl } = req.body;
|
|
|
|
if (!provider) {
|
|
return res.status(400).json({ error: 'Provider is required' });
|
|
}
|
|
|
|
const providerType = provider as AuthProvider;
|
|
// Generate reliable email & username if from SSO
|
|
const ssoEmail = email?.toLowerCase().trim() || `${provider}_golfer_${Date.now().toString(36)}@example.com`;
|
|
const ssoUsername = username?.toLowerCase().trim() || `${provider}_${Math.random().toString(36).substring(2, 7)}`;
|
|
const ssoName = name?.trim() || `${provider.charAt(0).toUpperCase() + provider.slice(1)} Golfer`;
|
|
|
|
// Check if user already exists
|
|
let user = db.findUserByEmailOrUsername(ssoEmail);
|
|
|
|
if (!user) {
|
|
// Create new user connected via SSO
|
|
const newUser: User = {
|
|
id: `usr_${provider}_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
|
email: ssoEmail,
|
|
username: ssoUsername,
|
|
name: ssoName,
|
|
avatarUrl: avatarUrl || (provider === 'authentik'
|
|
? 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=120&h=120&q=80'
|
|
: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=120&h=120&q=80'),
|
|
handicapIndex: 12.0,
|
|
homeClub: authentikServerUrl ? `Authentik SSO (${new URL(authentikServerUrl).hostname})` : 'Global Golf Tour',
|
|
isPublic: true,
|
|
provider: providerType,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
user = db.createUser(newUser);
|
|
}
|
|
|
|
const { passwordHash: _, ...safeUser } = user;
|
|
const token = generateToken(safeUser);
|
|
|
|
return res.json({
|
|
token,
|
|
user: safeUser,
|
|
settings: db.getSettings(safeUser.id),
|
|
});
|
|
} catch (error) {
|
|
console.error('SSO Login error:', error);
|
|
return res.status(500).json({ error: 'SSO authentication failed' });
|
|
}
|
|
});
|
|
|
|
export default router;
|