feat: add user token management

This commit is contained in:
Sonny
2025-08-21 16:39:02 +02:00
parent 376e9e32c3
commit d00b6b9edd
17 changed files with 517 additions and 2 deletions

View File

@@ -0,0 +1,32 @@
import { ApiTokenService } from '#user/services/api_token_service';
import { createApiTokenValidator } from '#user/validators/token/create_api_token';
import { deleteApiTokenValidator } from '#user/validators/token/delete_api_token';
import { inject } from '@adonisjs/core';
import { HttpContext } from '@adonisjs/core/http';
import { DateTime } from 'luxon';
@inject()
export default class ApiTokenController {
constructor(private apiTokenService: ApiTokenService) {}
async store({ request, response, auth }: HttpContext) {
const { name, expiresAt } = await request.validateUsing(
createApiTokenValidator
);
await this.apiTokenService.createToken({
user: auth.user!,
name,
expiresAt: expiresAt ? DateTime.fromJSDate(expiresAt) : undefined,
});
return response.redirect().withQs().back();
}
async destroy({ request, response, auth }: HttpContext) {
const { params } = await request.validateUsing(deleteApiTokenValidator);
const tokenId = params.tokenId;
await this.apiTokenService.revokeToken(tokenId, auth.user!.id);
return response.redirect().withQs().back();
}
}

View File

@@ -0,0 +1,52 @@
import AppBaseModel from '#core/models/app_base_model';
import User from '#user/models/user';
import { beforeSave, belongsTo, column } from '@adonisjs/lucid/orm';
import type { BelongsTo } from '@adonisjs/lucid/types/relations';
import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
export default class ApiToken extends AppBaseModel {
@column()
declare userId: number;
@column()
declare name: string;
@column()
declare token: string;
@column.dateTime()
declare lastUsedAt: DateTime | null;
@column.dateTime()
declare expiresAt: DateTime | null;
@column()
declare isActive: boolean;
@belongsTo(() => User, {
foreignKey: 'userId',
})
declare user: BelongsTo<typeof User>;
isExpired(): boolean {
if (!this.expiresAt) return false;
return DateTime.now() > this.expiresAt;
}
isValid(): boolean {
return this.isActive && !this.isExpired();
}
async markAsUsed(): Promise<void> {
this.lastUsedAt = DateTime.now();
await this.save();
}
@beforeSave()
static async generateToken(token: ApiToken) {
if (!token.token) {
token.token = randomBytes(32).toString('hex');
}
}
}

View File

@@ -3,6 +3,7 @@ import AppBaseModel from '#core/models/app_base_model';
import Link from '#links/models/link';
import { type DisplayPreferences } from '#shared/types/index';
import { ensureDisplayPreferences } from '#user/lib/index';
import ApiToken from '#user/models/api_token';
import type { GoogleToken } from '@adonisjs/ally/types';
import { column, computed, hasMany } from '@adonisjs/lucid/orm';
import type { HasMany } from '@adonisjs/lucid/types/relations';
@@ -43,6 +44,11 @@ export default class User extends AppBaseModel {
})
declare links: HasMany<typeof Link>;
@hasMany(() => ApiToken, {
foreignKey: 'userId',
})
declare apiTokens: HasMany<typeof ApiToken>;
@computed()
get fullname() {
return this.nickName || this.name;

View File

@@ -0,0 +1,15 @@
import { middleware } from '#start/kernel';
import router from '@adonisjs/core/services/router';
const ApiTokenController = () =>
import('#user/controllers/api_token_controller');
router
.group(() => {
router.post('/', [ApiTokenController, 'store']).as('user.api-tokens.store');
router
.delete('/:tokenId', [ApiTokenController, 'destroy'])
.as('user.api-tokens.destroy');
})
.prefix('/user/api-tokens')
.middleware([middleware.auth()]);

View File

@@ -1,2 +1,3 @@
import './api_token_routes.js';
import './user_display_preferences_route.js';
import './user_theme_route.js';

View File

@@ -0,0 +1,54 @@
import ApiToken from '#user/models/api_token';
import User from '#user/models/user';
import { DateTime } from 'luxon';
type CreateApiTokenPayload = {
user: User;
name: string;
expiresAt?: DateTime;
};
export class ApiTokenService {
async createToken({
user,
name,
expiresAt,
}: CreateApiTokenPayload): Promise<ApiToken> {
return await ApiToken.create({
userId: user.id,
name,
expiresAt,
isActive: true,
});
}
async getUserTokens(userId: number): Promise<ApiToken[]> {
return await ApiToken.query()
.where('userId', userId)
.orderBy('created_at', 'desc');
}
async revokeToken(tokenId: number, userId: number): Promise<void> {
const token = await ApiToken.query()
.where('id', tokenId)
.where('userId', userId)
.firstOrFail();
token.isActive = false;
await token.save();
}
async validateToken(tokenString: string): Promise<ApiToken | null> {
const token = await ApiToken.query()
.where('token', tokenString)
.where('isActive', true)
.first();
if (!token || !token.isValid()) {
return null;
}
await token.markAsUsed();
return token;
}
}

View File

@@ -0,0 +1,8 @@
import vine from '@vinejs/vine';
export const createApiTokenValidator = vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(255),
expiresAt: vine.date().optional(),
})
);

View File

@@ -0,0 +1,9 @@
import vine from '@vinejs/vine';
export const deleteApiTokenValidator = vine.compile(
vine.object({
params: vine.object({
tokenId: vine.number().positive(),
}),
})
);

View File

@@ -1,10 +1,17 @@
import { ApiTokenService } from '#user/services/api_token_service';
import { inject } from '@adonisjs/core';
import { HttpContext } from '@adonisjs/core/http';
@inject()
export default class ShowUserSettingsController {
constructor(private apiTokenService: ApiTokenService) {}
public async render({ auth, inertia }: HttpContext) {
const user = await auth.authenticate();
const tokens = await this.apiTokenService.getUserTokens(user.id);
return inertia.render('user_settings/show', {
user,
tokens,
});
}
}