diff --git a/source/server/db.ts b/source/server/db.ts index 1d343bc..9b2506c 100644 --- a/source/server/db.ts +++ b/source/server/db.ts @@ -94,15 +94,17 @@ export const refreshTokens = find: ( token: string ): RefreshToken | undefined => read( 'refreshTokens' ).find( t => t.token === token ), - create( userId: string ): RefreshToken + create( userId: string, ttlMs: number = 30 * 24 * 60 * 60 * 1000 ): RefreshToken { const token: RefreshToken = { token: randomUUID(), userId, - expiresAt: new Date( Date.now() + 30 * 24 * 60 * 60 * 1000 ).toISOString() + expiresAt: new Date( Date.now() + ttlMs ).toISOString() }; + write( 'refreshTokens', [ ...read( 'refreshTokens' ), token ] ); + return token; }, diff --git a/source/server/debug/LogColors.ts b/source/server/debug/LogColors.ts new file mode 100644 index 0000000..1d58ca5 --- /dev/null +++ b/source/server/debug/LogColors.ts @@ -0,0 +1,31 @@ +export class LogColors +{ + static readonly reset = "\x1b[0m"; + static readonly bright = "\x1b[1m"; + static readonly dim = "\x1b[2m"; + static readonly underscore = "\x1b[4m"; + static readonly blink = "\x1b[5m"; + static readonly reverse = "\x1b[7m"; + static readonly hidden = "\x1b[8m"; + + static readonly black_Foreground = "\x1b[30m"; + static readonly red_Foreground = "\x1b[31m"; + static readonly green_Foreground = "\x1b[32m"; + static readonly yellow_Foreground = "\x1b[33m"; + static readonly blue_Foreground = "\x1b[34m"; + static readonly magenta_Foreground = "\x1b[35m"; + static readonly cyan_Foreground = "\x1b[36m"; + static readonly white_Foreground = "\x1b[37m"; + static readonly gray_Foreground = "\x1b[90m"; + + static readonly black_Background = "\x1b[40m"; + static readonly red_Background = "\x1b[41m"; + static readonly green_Background = "\x1b[42m"; + static readonly yellow_Background = "\x1b[43m"; + static readonly blue_Background = "\x1b[44m"; + static readonly magenta_Background = "\x1b[45m"; + static readonly cyan_Background = "\x1b[46m"; + static readonly white_Background = "\x1b[47m"; + static readonly gray_Background = "\x1b[100m"; + +} \ No newline at end of file diff --git a/source/server/debug/RJLog.ts b/source/server/debug/RJLog.ts new file mode 100644 index 0000000..6680615 --- /dev/null +++ b/source/server/debug/RJLog.ts @@ -0,0 +1,136 @@ +import { LogColors } from "./LogColors"; + +export class RJLog +{ + static readonly errorColor = LogColors.red_Background; + static readonly errorMessageColor = LogColors.red_Foreground; + static readonly warnColor = LogColors.yellow_Background; + static readonly logColor = LogColors.gray_Background + static readonly resetColor = LogColors.reset; + + static readonly matcherWithFunction = /^\s+at\s(.+)\s\(.+?:(\d+:\d+)\)/; + static readonly matcherFile = /\(.+?\\(\w+)\.js:(\d+:\d+)\)/; + static readonly matcherAnonymous = /^\s+at\s(.+)\s\((.+)\)/; + + static readonly logAlwaysLineInfo = true; + + + static _parseLineResult( line:string ):RegExpExecArray|null + { + let result = RJLog.matcherWithFunction.exec( line ) || + RJLog.matcherFile.exec( line ) || + RJLog.matcherAnonymous.exec( line ); + + return result; + } + + static _parseLine( line:string ):string + { + let result = RJLog._parseLineResult( line ); + + if ( result ) + { + return " " + result[ 1 ] + "(" + result[ 2 ] + ") "; + } + + return line; + } + + static logError( e:Error ) + { + console.log( "\n" + RJLog._formatErrorMessage( ( e as any ) .stack ) ); + } + + static splitLines( text:string ) + { + return text.split( /(?:\r\n)|\n|\r/g ); + } + + static _formatErrorMessage( stackTrace:string, color:string = RJLog.errorMessageColor ) + { + let lines = RJLog.splitLines( stackTrace ); + let output:string[] = [ color ]; + + lines.forEach( + ( line, index ) => + { + let lineInfo = RJLog._parseLine( line ); + + output.push( lineInfo ); + + if ( index !== lines.length - 1 ) + { + output.push( "\n" ); + } + + } + ) + + output.push( RJLog.resetColor ); + + return output.join( "" ); + } + + static getLineInfo( color:string = RJLog.logColor, stackTrace?:string, lineIndex:number = 3 ) + { + stackTrace = stackTrace || ( new Error().stack + "" ); + + let lines = RJLog.splitLines( stackTrace ); + + let result:RegExpExecArray|null = null; + + while ( ! result && lineIndex < lines.length ) + { + let line = lines[ lineIndex ]; + + result = RJLog._parseLineResult( line ) ; + + lineIndex ++; + } + + + if ( ! result ) + { + console.log( stackTrace ); + return color + " " + RJLog.resetColor ; + } + + return color + " " + result[ 1 ] + "(" + result[ 2 ] + ") " + RJLog.resetColor; + } + + + static error( ...params:any[] ) + { + if ( RJLog.logAlwaysLineInfo || typeof process === "object" ) + { + let lineInfo = RJLog.getLineInfo( RJLog.errorColor ); + console.log( "\n" + lineInfo ); + } + + console.error.apply( console, params ); + + } + + static warn( ...params:any[] ) + { + if ( RJLog.logAlwaysLineInfo || typeof process === "object" ) + { + let lineInfo = RJLog.getLineInfo( RJLog.warnColor ); + console.log( "\n" + lineInfo ); + } + + console.warn.apply( console, params ); + } + + + static log( ...params:any[] ) + { + if ( RJLog.logAlwaysLineInfo || typeof process === "object" ) + { + let lineInfo = RJLog.getLineInfo(); + console.log( "\n" + lineInfo ); + } + + console.log.apply( console, params ); + } +} \ No newline at end of file diff --git a/source/server/routes/auth.ts b/source/server/routes/auth.ts index a220c2d..7f60524 100644 --- a/source/server/routes/auth.ts +++ b/source/server/routes/auth.ts @@ -6,17 +6,33 @@ import { requireAuth } from '../middleware/requireAuth'; import { EmailService } from '../email/EmailService'; import { checkForgotPasswordRate, checkLoginRate, checkRegisterRate, sleep } from '../rateLimiter'; import { isSuperAdmin } from '../roles'; +import { RJLog } from '../debug/RJLog'; const router = Router(); -const ACCESS_TOKEN_TTL = ( process.env.ACCESS_TOKEN_TTL ?? '1h' ) as any; -const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com'; +function parseTtlMs( ttl: string ): number +{ + const n = parseInt( ttl, 10 ); + if ( ttl.endsWith( 'ms' ) ) return n; + if ( ttl.endsWith( 's' ) ) return n * 1_000; + if ( ttl.endsWith( 'm' ) ) return n * 60_000; + if ( ttl.endsWith( 'h' ) ) return n * 3_600_000; + if ( ttl.endsWith( 'd' ) ) return n * 86_400_000; + if ( ttl.endsWith( 'w' ) ) return n * 604_800_000; + return 3_600_000; +} + +const ACCESS_TOKEN_TTL = ( process.env.ACCESS_TOKEN_TTL ?? '1h' ) as any; +const ACCESS_TOKEN_TTL_MS = parseTtlMs( process.env.ACCESS_TOKEN_TTL ?? '1h' ); +const REFRESH_TOKEN_TTL_MS = parseTtlMs( process.env.REFRESH_TOKEN_TTL ?? '30d' ); +const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com'; const RESET_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com'; const ACCOUNT_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com'; function issueAccessToken( userId: string ): string { const user = users.findById( userId )!; + const payload = { userId: user.id, @@ -25,6 +41,7 @@ function issueAccessToken( userId: string ): string products: user.products.map( p => p.id ), settings: user.settings }; + return jwt.sign( payload, process.env.JWT_SECRET ?? '', { expiresIn: ACCESS_TOKEN_TTL } ); } @@ -41,12 +58,12 @@ function cookieOptions( maxAge: number ) function setTokenCookie( res: Response, accessToken: string ): void { - res.cookie( 'accessToken', accessToken, { ...cookieOptions( 60 * 60 * 1000 ), maxAge: 60 * 60 * 1000 } ); + res.cookie( 'accessToken', accessToken, { ...cookieOptions( ACCESS_TOKEN_TTL_MS ), maxAge: ACCESS_TOKEN_TTL_MS } ); } function setRefreshTokenCookie( res: Response, token: string ): void { - res.cookie( 'refreshToken', token, { ...cookieOptions( 30 * 24 * 60 * 60 * 1000 ), maxAge: 30 * 24 * 60 * 60 * 1000 } ); + res.cookie( 'refreshToken', token, { ...cookieOptions( REFRESH_TOKEN_TTL_MS ), maxAge: REFRESH_TOKEN_TTL_MS } ); } function clearAuthCookies( res: Response ): void @@ -59,274 +76,437 @@ function clearAuthCookies( res: Response ): void function issueTokenPair( res: Response, userId: string ): { accessToken: string; refreshToken: string } { const accessToken = issueAccessToken( userId ); - const refreshRecord = refreshTokens.create( userId ); + const refreshRecord = refreshTokens.create( userId, REFRESH_TOKEN_TTL_MS ); + setTokenCookie( res, accessToken ); setRefreshTokenCookie( res, refreshRecord.token ); + return { accessToken, refreshToken: refreshRecord.token }; } -// POST /api/auth/register -router.post( '/register', async ( req, res ) => -{ - const ip = req.ip ?? 'unknown'; - const { blocked } = checkRegisterRate( ip ); - if ( blocked ) { res.status( 429 ).json( { error: 'Too many registrations. Try again later.' } ); return; } +// POST /api/auth/register - Create new user +router.post( '/register', - const { email, password } = req.body as { email?: string; password?: string }; - if ( !email || !password ) + async ( req, res ) => { - res.status( 400 ).json( { error: 'Email and password required' } ); - return; - } - try - { - const passwordHash = await bcrypt.hash( password, 10 ); - const user = users.create( email, passwordHash ); + const ip = req.ip ?? 'unknown'; + const { blocked } = checkRegisterRate( ip ); + if ( blocked ) { res.status( 429 ).json( { error: 'Too many registrations. Try again later.' } ); return; } - const superadminEmail = process.env.INITIAL_SUPERADMIN_EMAIL?.toLowerCase(); - if ( superadminEmail && email.toLowerCase() === superadminEmail ) + const { email, password } = req.body as { email?: string; password?: string }; + + if ( ! email || ! password ) { - const alreadyHasSuperAdmin = users.all().some( u => u.id !== user.id && isSuperAdmin( u.roles ) ); - if ( !alreadyHasSuperAdmin ) - users.update( user.id, { roles: [ 'superadmin', 'user' ] } ); + res.status( 400 ).json( { error: 'Email and password required' } ); + return; } - const tokens = issueTokenPair( res, user.id ); + try + { + const passwordHash = await bcrypt.hash( password, 10 ); + const user = users.create( email, passwordHash ); - // Welcome email — non-blocking - EmailService.sendEmail( - email, - 'Welcome to rokojori', - `Hi,\n\nYour account has been created at ${ACCOUNT_BASE_URL}.\n\nIf you did not create this account or want to delete it, visit:\n${ACCOUNT_BASE_URL}/profile.html\n\nYou can delete your account there at any time.` - ).catch( () => {} ); + const superadminEmail = process.env.INITIAL_SUPERADMIN_EMAIL?.toLowerCase(); - res.json( tokens ); - } - catch + if ( superadminEmail && email.toLowerCase() === superadminEmail ) + { + const alreadyHasSuperAdmin = users.all().some( u => u.id !== user.id && isSuperAdmin( u.roles ) ); + if ( !alreadyHasSuperAdmin ) + { + users.update( user.id, { roles: [ 'superadmin', 'user' ] } ); + } + } + + const tokens = issueTokenPair( res, user.id ); + + // Welcome email — non-blocking + EmailService.sendEmail( + email, + 'Welcome to rokojori', + `Hi,\n\nYour account has been created at ${ACCOUNT_BASE_URL}.\n\nIf you did not create this account or want to delete it, visit:\n${ACCOUNT_BASE_URL}/profile.html\n\nYou can delete your account there at any time.` + ).catch( () => {} ); + + res.json( tokens ); + } + catch + { + res.status( 409 ).json( { error: 'Email already registered' } ); + } + } + +); + +// POST /api/auth/login - Login user +router.post( '/login', + + async ( req, res ) => { - res.status( 409 ).json( { error: 'Email already registered' } ); - } -} ); + const ip = req.ip ?? 'unknown'; -// POST /api/auth/login -router.post( '/login', async ( req, res ) => -{ - const ip = req.ip ?? 'unknown'; - const { blocked, delay } = checkLoginRate( ip ); - if ( blocked ) { res.status( 429 ).json( { error: 'Too many attempts. Try again later.' } ); return; } - if ( delay ) await sleep( delay ); + const { blocked, delay } = checkLoginRate( ip ); - const { email, password } = req.body as { email?: string; password?: string }; - const user = email ? users.findByEmail( email ) : undefined; - if ( !user || !password || !( await bcrypt.compare( password, user.passwordHash ) ) ) - { - res.status( 401 ).json( { error: 'Invalid credentials' } ); - return; - } - res.json( issueTokenPair( res, user.id ) ); -} ); + if ( blocked ) + { + res.status( 429 ).json( { error: 'Too many attempts. Try again later.' } ); + + return; + } + + if ( delay ) + { + await sleep( delay ); + } + + const { email, password } = req.body as { email?: string; password?: string }; + + const user = email ? users.findByEmail( email ) : undefined; + + if ( ! user || ! password || ! ( await bcrypt.compare( password, user.passwordHash ) ) ) + { + res.status( 401 ).json( { error: 'Invalid credentials' } ); + + return; + } + + let tokenData = issueTokenPair( res, user.id ); + + RJLog.log( "Logging in:", user.id, tokenData ); + + res.json( tokenData ); + + } +); // POST /api/auth/logout -router.post( '/logout', ( req, res ) => -{ - const tokenFromBody = ( req.body as { refreshToken?: string } ).refreshToken; - const tokenFromCookie = req.cookies?.refreshToken as string | undefined; - const token = tokenFromBody ?? tokenFromCookie; - if ( token ) refreshTokens.delete( token ); - clearAuthCookies( res ); - res.json( { ok: true } ); -} ); +router.post( '/logout', + + ( req, res ) => + { + const tokenFromBody = ( req.body as { refreshToken?: string } ).refreshToken; + const tokenFromCookie = req.cookies?.refreshToken as string | undefined; + const token = tokenFromBody ?? tokenFromCookie; + + if ( token ) + { + refreshTokens.delete( token ); + } + + clearAuthCookies( res ); + res.json( { ok: true } ); + } +); // GET /api/auth/logout?redirect=... — browser clients (link/redirect-based logout) -router.get( '/logout', ( req, res ) => -{ - const redirectTo = req.query.redirect as string | undefined; - const token = req.cookies?.refreshToken as string | undefined; - if ( token ) refreshTokens.delete( token ); - clearAuthCookies( res ); - res.redirect( redirectTo ?? '/login.html' ); -} ); +router.get( '/logout', + + ( req, res ) => + { + const redirectTo = req.query.redirect as string | undefined; + const token = req.cookies?.refreshToken as string | undefined; + if ( token ) refreshTokens.delete( token ); + clearAuthCookies( res ); + res.redirect( redirectTo ?? '/login.html' ); + } +); // POST /api/auth/refresh — non-browser clients (token in body) -router.post( '/refresh', ( req, res ) => -{ - const { refreshToken } = req.body as { refreshToken?: string }; - if ( !refreshToken ) { res.status( 400 ).json( { error: 'Refresh token required' } ); return; } +router.post( '/refresh', - const record = refreshTokens.find( refreshToken ); - if ( !record || new Date( record.expiresAt ) < new Date() ) + ( req, res ) => { - res.status( 401 ).json( { error: 'Invalid or expired refresh token' } ); - return; - } - const user = users.findById( record.userId ); - if ( !user ) { res.status( 401 ).json( { error: 'User not found' } ); return; } + const { refreshToken } = req.body as { refreshToken?: string }; - refreshTokens.delete( refreshToken ); - res.json( issueTokenPair( res, user.id ) ); -} ); + if ( ! refreshToken ) + { + RJLog.log( "Refresh token required, but got", req.body ); -// GET /api/auth/refresh-session?redirect=... — browser clients (token from cookie) -router.get( '/refresh-session', ( req, res ) => -{ - const redirectTo = req.query.redirect as string | undefined; - const loginFallback = redirectTo - ? `/login.html?redirect=${encodeURIComponent( redirectTo )}` - : '/login.html'; + res.status( 400 ).json( { error: 'Refresh token required' } ); + return; + } - const token = req.cookies?.refreshToken as string | undefined; - if ( !token ) { res.redirect( loginFallback ); return; } + const record = refreshTokens.find( refreshToken ); - const record = refreshTokens.find( token ); - if ( !record || new Date( record.expiresAt ) < new Date() ) - { - clearAuthCookies( res ); - res.redirect( loginFallback ); - return; - } - const user = users.findById( record.userId ); - if ( !user ) { clearAuthCookies( res ); res.redirect( loginFallback ); return; } + if ( ! record || new Date( record.expiresAt ) < new Date() ) + { + RJLog.log( "Invalid or expired refresh token", refreshToken ); - refreshTokens.delete( token ); - issueTokenPair( res, user.id ); - res.redirect( redirectTo ?? '/profile.html' ); -} ); + res.status( 401 ).json( { error: 'Invalid or expired refresh token' } ); + + return; + } + + const user = users.findById( record.userId ); + + if ( ! user ) + { + RJLog.log( "User not found", refreshToken, record ); + res.status( 401 ).json( { error: 'User not found' } ); + + return; + } + + refreshTokens.delete( refreshToken ); + + let tokenData = issueTokenPair( res, user.id ); + + RJLog.log( "Refreshing token", user.id, tokenData ); + + res.json( tokenData ); + } + +); + +// DEPRECATED +// // GET /api/auth/refresh-session?redirect=... — browser clients (token from cookie) +// router.get( '/refresh-session', + +// ( req, res ) => +// { +// const redirectTo = req.query.redirect as string | undefined; +// const loginFallback = redirectTo +// ? `/login.html?redirect=${encodeURIComponent( redirectTo )}` +// : '/login.html'; + +// const token = req.cookies?.refreshToken as string | undefined; +// if ( !token ) { res.redirect( loginFallback ); return; } + +// const record = refreshTokens.find( token ); +// if ( !record || new Date( record.expiresAt ) < new Date() ) +// { +// clearAuthCookies( res ); +// res.redirect( loginFallback ); +// return; +// } +// const user = users.findById( record.userId ); +// if ( !user ) { clearAuthCookies( res ); res.redirect( loginFallback ); return; } + +// refreshTokens.delete( token ); +// issueTokenPair( res, user.id ); +// res.redirect( redirectTo ?? '/profile.html' ); +// } +// ); // POST /api/auth/forgot-password — rate-limited with escalating delay -router.post( '/forgot-password', async ( req, res ) => -{ - const ip = req.ip ?? 'unknown'; - const { blocked, delay } = checkForgotPasswordRate( ip ); - if ( blocked ) { res.status( 429 ).json( { error: 'Too many attempts. Try again later.' } ); return; } - await sleep( delay ); - - const { email } = req.body as { email?: string }; - if ( !email ) { res.status( 400 ).json( { error: 'Email required' } ); return; } - - const user = users.findByEmail( email ); - if ( user ) +router.post( '/forgot-password', + + async ( req, res ) => { - const token = resetTokens.create( user.id ); - const link = `${RESET_BASE_URL}/reset-password.html?token=${token.token}`; - await EmailService.sendEmail( - email, - 'Reset your password — rokojori', - `Hi,\n\nClick the link below to reset your password. It expires in 1 hour.\n\n${link}\n\nIf you did not request this, you can safely ignore this email.` - ); - } + const ip = req.ip ?? 'unknown'; + const { blocked, delay } = checkForgotPasswordRate( ip ); - res.json( { ok: true } ); -} ); + if ( blocked ) + { + res.status( 429 ).json( { error: 'Too many attempts. Try again later.' } ); + + return; + } + + await sleep( delay ); + + const { email } = req.body as { email?: string }; + if ( !email ) + { + res.status( 400 ).json( { error: 'Email required' } ); + return; + } + + const user = users.findByEmail( email ); + + if ( user ) + { + const token = resetTokens.create( user.id ); + const link = `${RESET_BASE_URL}/reset-password.html?token=${token.token}`; + await EmailService.sendEmail( + email, + 'Reset your password — rokojori', + `Hi,\n\nClick the link below to reset your password. It expires in 1 hour.\n\n${link}\n\nIf you did not request this, you can safely ignore this email.` + ); + } + + res.json( { ok: true } ); + } +); // POST /api/auth/reset-password -router.post( '/reset-password', async ( req, res ) => -{ - const { token, password } = req.body as { token?: string; password?: string }; - if ( !token || !password ) { res.status( 400 ).json( { error: 'Token and password required' } ); return; } +router.post( '/reset-password', - const record = resetTokens.find( token ); - if ( !record || new Date( record.expiresAt ) < new Date() ) + async ( req, res ) => { - res.status( 400 ).json( { error: 'Invalid or expired token' } ); - return; - } - const passwordHash = await bcrypt.hash( password, 10 ); - users.update( record.userId, { passwordHash } ); - resetTokens.delete( token ); - res.json( { ok: true } ); -} ); + const { token, password } = req.body as { token?: string; password?: string }; + if ( ! token || ! password ) + { + res.status( 400 ).json( { error: 'Token and password required' } ); + return; + } + + const record = resetTokens.find( token ); + + if ( ! record || new Date( record.expiresAt ) < new Date() ) + { + res.status( 400 ).json( { error: 'Invalid or expired token' } ); + return; + } + + const passwordHash = await bcrypt.hash( password, 10 ); + + users.update( record.userId, { passwordHash } ); + resetTokens.delete( token ); + res.json( { ok: true } ); + } +); // GET /api/auth/reset-token-email -router.get( '/reset-token-email', ( req, res ) => -{ - const token = req.query.token as string | undefined; - const record = token ? resetTokens.find( token ) : undefined; - if ( !record || new Date( record.expiresAt ) < new Date() ) +router.get( '/reset-token-email', + + ( req, res ) => { - res.status( 400 ).json( { error: 'Invalid or expired token' } ); - return; - } - const user = users.findById( record.userId ); - if ( !user ) { res.status( 400 ).json( { error: 'User not found' } ); return; } - res.json( { email: user.email } ); -} ); + const token = req.query.token as string | undefined; + const record = token ? resetTokens.find( token ) : undefined; + if ( !record || new Date( record.expiresAt ) < new Date() ) + { + res.status( 400 ).json( { error: 'Invalid or expired token' } ); + return; + } + const user = users.findById( record.userId ); + if ( !user ) { res.status( 400 ).json( { error: 'User not found' } ); return; } + res.json( { email: user.email } ); + } +); // POST /api/auth/lookup-email — server-to-server; requires SERVICE_SECRET -router.post( '/lookup-email', ( req, res ) => -{ - const secret = process.env.SERVICE_SECRET; - const auth = req.headers.authorization; +router.post( '/lookup-email', - if ( !secret || auth !== `Bearer ${secret}` ) + ( req, res ) => { - res.status( 401 ).json( { error: 'Unauthorized' } ); - return; - } + const secret = process.env.SERVICE_SECRET; + const auth = req.headers.authorization; - const { email } = req.body as { email?: string }; - if ( !email ) { res.status( 400 ).json( { error: 'Email required' } ); return; } + if ( ! secret || auth !== `Bearer ${secret}` ) + { + res.status( 401 ).json( { error: 'Unauthorized' } ); - const user = users.findByEmail( email ); - if ( !user ) { res.status( 404 ).json( { error: 'No account found for that email' } ); return; } + return; + } - res.json( { id: user.id, email: user.email } ); -} ); + const { email } = req.body as { email?: string }; + + if ( ! email ) + { + res.status( 400 ).json( { error: 'Email required' } ); + + return; + } + + const user = users.findByEmail( email ); + + if ( ! user ) + { + res.status( 404 ).json( { error: 'No account found for that email' } ); + + return; + } + + res.json( { id: user.id, email: user.email } ); + } +); // GET /api/auth/me -router.get( '/me', requireAuth, ( req, res ) => -{ - const user = users.findById( req.auth!.userId ); - if ( !user ) { res.status( 404 ).json( { error: 'User not found' } ); return; } - res.json( +router.get( '/me', requireAuth, + + ( req, res ) => { - id: user.id, - email: user.email, - roles: user.roles, - products: user.products, - settings: user.settings - } ); -} ); + const user = users.findById( req.auth!.userId ); + + if ( !user ) + { + res.status( 404 ).json( { error: 'User not found' } ); + return; + } + + res.json( + { + id: user.id, + email: user.email, + roles: user.roles, + products: user.products, + settings: user.settings + } + ); + + } +); // PATCH /api/auth/me/settings -router.patch( '/me/settings', requireAuth, ( req, res ) => -{ - const user = users.findById( req.auth!.userId ); - if ( !user ) { res.status( 404 ).json( { error: 'User not found' } ); return; } - const settings = { ...user.settings, ...( req.body as Record ) }; - users.update( req.auth!.userId, { settings } ); - res.json( { settings } ); -} ); +router.patch( '/me/settings', requireAuth, + + ( req, res ) => + { + const user = users.findById( req.auth!.userId ); + if ( ! user ) + { + res.status( 404 ).json( { error: 'User not found' } ); return; + } + + const settings = { ...user.settings, ...( req.body as Record ) }; + + users.update( req.auth!.userId, { settings } ); + + res.json( { settings } ); + + } +); // POST /api/auth/me/password -router.post( '/me/password', requireAuth, async ( req, res ) => -{ - const { currentPassword, newPassword } = req.body as { currentPassword?: string; newPassword?: string }; - if ( !currentPassword || !newPassword ) +router.post( '/me/password', + + requireAuth, async ( req, res ) => { - res.status( 400 ).json( { error: 'Current and new password required' } ); - return; - } - const user = users.findById( req.auth!.userId ); - if ( !user ) { res.status( 404 ).json( { error: 'User not found' } ); return; } - if ( !( await bcrypt.compare( currentPassword, user.passwordHash ) ) ) - { - res.status( 401 ).json( { error: 'Current password is incorrect' } ); - return; - } - const passwordHash = await bcrypt.hash( newPassword, 10 ); - users.update( user.id, { passwordHash } ); - res.json( { ok: true } ); -} ); + const { currentPassword, newPassword } = req.body as { currentPassword?: string; newPassword?: string }; + + if ( ! currentPassword || ! newPassword ) + { + res.status( 400 ).json( { error: 'Current and new password required' } ); + + return; + } + + const user = users.findById( req.auth!.userId ); + if ( !user ) + { + res.status( 404 ).json( { error: 'User not found' } ); + + return; + } + + if ( ! ( await bcrypt.compare( currentPassword, user.passwordHash ) ) ) + { + res.status( 401 ).json( { error: 'Current password is incorrect' } ); + return; + } + + const passwordHash = await bcrypt.hash( newPassword, 10 ); + + users.update( user.id, { passwordHash } ); + + res.json( { ok: true } ); + } +); // DELETE /api/auth/me -router.delete( '/me', requireAuth, ( req, res ) => -{ - const userId = req.auth!.userId; - refreshTokens.deleteForUser( userId ); - resetTokens.deleteForUser( userId ); - users.delete( userId ); - clearAuthCookies( res ); - res.json( { ok: true } ); -} ); +router.delete( '/me', requireAuth, + + ( req, res ) => + { + const userId = req.auth!.userId; + + refreshTokens.deleteForUser( userId ); + resetTokens.deleteForUser( userId ); + users.delete( userId ); + + clearAuthCookies( res ); + + res.json( { ok: true } ); + } +); export default router;