Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@ SMTP_USERNAME=noreply@local.host
SMTP_PASSWORD=localpassword
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=thisisaverysecurepassword
REDIS_PASSWORD=thisisaverysecurepassword
65 changes: 64 additions & 1 deletion app/controllers/auth_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,23 @@

import User from '#models/user'
import { UserGuard } from '#utils/permissions'
import { loginValidator, providerParamValidator, registerValidator } from '#validators/auth'
import {
forgotPasswordValidator,
loginValidator,
providerParamValidator,
registerValidator,
resetPasswordValidator,
} from '#validators/auth'
import type { HttpContext } from '@adonisjs/core/http'
import {
ApiOperation,
ApiRequest,
ApiResponse,
} from '#openapi/decorators'
import { generateSecureToken } from '#utils/teams'
import { DateTime } from 'luxon'
import mail from '@adonisjs/mail/services/main'
import env from '#start/env'

export default class AuthController {
@ApiOperation({ description: 'Redirects to the social provider for authentication' })
Expand Down Expand Up @@ -123,4 +133,57 @@ export default class AuthController {
await auth.use('web').logout()
return response.redirect('/')
}

@ApiOperation({ description: 'Send password reset request for a user' })
@ApiRequest({ validator: forgotPasswordValidator, withResponse: true })
@ApiResponse(200, { description: 'Password reset email sent' })
@ApiResponse(404, { description: 'User not found or uses social login' })
public async forgotPassword({ request, response }: HttpContext) {
const { email } = await request.validateUsing(forgotPasswordValidator)
const user = await User.findBy('email', email)

if (!user)
return response.notFound({ message: 'User not found' })

user.passwordResetToken = generateSecureToken()
user.passwordResetExpires = DateTime.now().plus({ minutes: 15 })
await user.save()

await mail.sendLater((message) => {
message
.to(user.email)
.subject('Password Reset Request')
.htmlView('user/password_reset', {
user: { name: user.nickname },
resetLink: `${env.get('WEBSITE')}reset-password?token=${user.passwordResetToken}`,
linkExpiryTime: user.passwordResetExpires!.toLocal().toLocaleString(DateTime.DATETIME_MED),
})
})

return response.ok({ message: 'Password reset email sent' })
}

@ApiOperation({ description: 'Resets user password using a reset token' })
@ApiRequest({ validator: resetPasswordValidator, withResponse: true })
@ApiResponse(200, { description: 'Password reset successful' })
@ApiResponse(400, { description: 'Invalid or expired token' })
public async resetPassword({ request, response }: HttpContext) {
const { qs, newPassword } = await request.validateUsing(resetPasswordValidator, {
data: {
...request.body(),
qs: request.qs(),
},
})

const user = await User.findBy('passwordResetToken', qs.token)
if (!user || user.passwordResetExpires!.diffNow().as('milliseconds') < 0)
return response.badRequest({ message: 'Invalid or expired token' })

user.password = newPassword
user.passwordResetToken = null
user.passwordResetExpires = null
await user.save()

return response.ok({ message: 'Password reset successful! You can now login.' })
}
}
6 changes: 6 additions & 0 deletions app/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ export default class User extends compose(BaseModel, AuthFinder) {
@column({ serializeAs: null })
declare password: string | null

@column({ serializeAs: null })
declare passwordResetToken: string | null

@column.dateTime({ serializeAs: null })
declare passwordResetExpires: DateTime | null

@column.dateTime({ autoCreate: true })
@ApiColumn(String, { format: 'date-time' })
declare createdAt: DateTime
Expand Down
50 changes: 28 additions & 22 deletions app/validators/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,30 +23,36 @@

import vine from '@vinejs/vine'

export const providerParamValidator = vine.create(
vine.object({
params: vine.object({
provider: vine.enum(['discord', 'github']),
}),
export const providerParamValidator = vine.create({
params: vine.object({
provider: vine.enum(['discord', 'github']),
}),
)
})

export const registerValidator = vine.create(
vine.object({
nickname: vine.string().trim().minLength(3).maxLength(16),
name: vine.string().trim().minLength(3).maxLength(32).optional(),
surname: vine.string().trim().minLength(3).maxLength(32).optional(),
email: vine.string().email().trim().unique(async (db, value) => {
const user = await db.from('users').where('email', value).first()
return !user
}),
password: vine.string().minLength(8).confirmed(),
export const registerValidator = vine.create({
nickname: vine.string().trim().minLength(3).maxLength(16),
name: vine.string().trim().minLength(3).maxLength(32).optional(),
surname: vine.string().trim().minLength(3).maxLength(32).optional(),
email: vine.string().email().trim().unique(async (db, value) => {
const user = await db.from('users').where('email', value).first()
return !user
}),
)
password: vine.string().minLength(8).confirmed(),
})

export const loginValidator = vine.create(
vine.object({
email: vine.string().email().trim(),
password: vine.string(),
export const loginValidator = vine.create({
email: vine.string().email().trim(),
password: vine.string(),
})

export const forgotPasswordValidator = vine.create({
email: vine.string().email().trim(),
})

export const resetPasswordValidator = vine.create({
qs: vine.object({
token: vine.string().trim(),
}),
)
newPassword: vine.string().confirmed({ as: 'newPasswordConfirm' }),
newPasswordConfirm: vine.string(),
})
3 changes: 3 additions & 0 deletions database/migrations/1771616940946_create_users_table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export default class extends BaseSchema {
table.integer('permissions').notNullable()
table.string('password').nullable()

table.string('password_reset_token').nullable()
table.dateTime('password_reset_expires').nullable()

table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
})
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/openapi.json

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions resources/views/events/invite.edge
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,10 @@
<div class="header">
<h1 class="logo">{{branding.name}}</h1>
</div>

<div class="content">
<h2 class="title">You've been invited</h2>

<div class="greeting">
<p>Hi {{ invitee.name }},</p>
<p><strong>{{ inviter.name }}</strong> has invited you to participate in an upcoming event on xContest.</p>
Expand Down Expand Up @@ -204,9 +204,9 @@
</div>

<div class="footer">
<p>&copy; 2026 xContest. Licensed under <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPLv3</a>.</p>
<p><a href="{{ unsubscribeLink }}">Unsubscribe</a></p>
<p>&copy; 2026 xcontest. licensed under <a href="https://www.gnu.org/licenses/agpl-3.0.html">agplv3</a>.</p>
<p><a href="{{ unsubscribeLink }}">unsubscribe</a></p>
</div>
</div>
</body>
</html>
</html>
146 changes: 146 additions & 0 deletions resources/views/user/password_reset.edge
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #111827;
background-color: #f3f4f6;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
}
.wrapper {
padding: 28px 16px;
}
.container {
max-width: 560px;
margin: 0 auto;
background-color: #ffffff;
border: 1px solid #d1d5db;
border-radius: 12px;
box-shadow: 0 10px 40px -8px rgba(0, 0, 0, 0.18), 0 4px 16px -4px rgba(0, 0, 0, 0.10), 0 1px 4px 0 rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.header {
padding: 28px 32px 0;
}
.logo {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.02em;
color: #111827;
margin: 0;
}
.content {
padding: 20px 32px 32px;
}
.title {
font-size: 24px;
font-weight: 600;
color: #111827;
margin: 0 0 12px 0;
letter-spacing: -0.01em;
}
.greeting {
font-size: 15px;
color: #374151;
margin-bottom: 16px;
}
.greeting p {
margin: 0 0 10px 0;
}
.reset-card {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 24px;
margin: 16px 0 0;
text-align: center;
}
.reset-card h3 {
margin: 0 0 16px 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
font-weight: 600;
}
.btn-reset {
display: inline-block;
width: 100%;
box-sizing: border-box;
padding: 12px 16px;
background-color: #111827;
color: #ffffff !important;
text-decoration: none !important;
border: 1.5px solid #111827;
border-radius: 6px;
font-weight: 600;
font-size: 14px;
text-align: center;
letter-spacing: 0.01em;
}
.expiry-note {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
color: #6b7280;
font-size: 13px;
}
.help-text {
margin-top: 24px;
color: #9ca3af;
font-size: 13px;
line-height: 1.5;
}
.footer {
padding: 24px 20px;
text-align: center;
font-size: 13px;
color: #9ca3af;
}
.footer a {
color: #6b7280;
text-decoration: underline;
text-underline-offset: 2px;
}
</style>
</head>
<body>
<div class="wrapper">
<div class="container">
<div class="header">
<h1 class="logo">{{ branding.name }}</h1>
</div>

<div class="content">
<h2 class="title">Reset your password</h2>

<div class="greeting">
<p>Hi {{ user.name }},</p>
<p>We received a request to reset the password for your <strong>{{ branding.name }}</strong> account. Click the button below to choose a new one.</p>
</div>

<div class="reset-card">
<a href="{{ resetLink }}" class="btn-reset">Reset Password</a>
<div class="expiry-note">
This link will expire on {{ linkExpiryTime }}.
</div>
</div>

<div class="help-text">
<strong>Didn't request this?</strong> If you didn't mean to reset your password, you can safely ignore this email. Your password will remain unchanged.
</div>
</div>
</div>

<div class="footer">
<p>&copy; 2026 xcontest. licensed under <a href="https://www.gnu.org/licenses/agpl-3.0.html">agplv3</a>.</p>
</div>
</div>
</body>
</html>
13 changes: 8 additions & 5 deletions start/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,20 @@
* _> </ /___/ /_/ / / / / /_/ __(__ ) /_
* /_/|_|\____/\____/_/ /_/\__/\___/____/\__/
* Copyright (C) 2026 xContest Team
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*
*/

import { Queue } from 'bullmq'
Expand All @@ -40,6 +40,9 @@ export const emailsQueue = new Queue('emails', {
*/
mail.setMessenger((mailer) => ({
async queue(mailMessage, config) {
if (app.inTest)
return

await emailsQueue.add('send_email', {
mailMessage,
config,
Expand All @@ -50,4 +53,4 @@ mail.setMessenger((mailer) => ({

app.terminating(async () => {
await emailsQueue.close()
})
})
3 changes: 3 additions & 0 deletions start/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ router.group(() => {
router.post('login', [AuthController, 'login'])

router.post('logout', [AuthController, 'logout']).use(middleware.auth())

router.post('forgot-password', [AuthController, 'forgotPassword'])
router.post('reset-password', [AuthController, 'resetPassword'])
}).prefix('auth')

const EventsController = () => import('#controllers/events_controller')
Expand Down
Loading