mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-09 15:05:35 +00:00
Compare commits
9 Commits
376e9e32c3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70fc82dea8 | ||
|
|
3591d032c2 | ||
|
|
e4aea038fe | ||
|
|
d03004b841 | ||
|
|
64cd4820c5 | ||
|
|
1c81a6a86f | ||
|
|
208f2c631f | ||
|
|
9aa71dad30 | ||
|
|
d00b6b9edd |
@@ -1,6 +1,6 @@
|
|||||||
# Source : https://github.com/adonisjs-community/adonis-packages/blob/main/Dockerfile
|
# Source : https://github.com/adonisjs-community/adonis-packages/blob/main/Dockerfile
|
||||||
|
|
||||||
FROM node:24.6-alpine3.22 AS base
|
FROM node:24.11-alpine3.22 AS base
|
||||||
|
|
||||||
RUN apk --no-cache add curl
|
RUN apk --no-cache add curl
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -92,6 +92,27 @@ pnpm run start
|
|||||||
openssl rand -base64 32
|
openssl rand -base64 32
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Google OAuth
|
||||||
|
|
||||||
|
Pour obtenir le Client ID et Secret Google nécessaires à l'authentification :
|
||||||
|
|
||||||
|
1. Accédez à la [Console Google Cloud](https://console.cloud.google.com/)
|
||||||
|
2. Créez un nouveau projet ou sélectionnez un projet existant
|
||||||
|
3. Activez l'API **Google+ API** (ou utilisez directement l'API OAuth 2.0)
|
||||||
|
4. Allez dans **Identifiants** (Credentials) > **Créer des identifiants** > **ID client OAuth 2.0**
|
||||||
|
5. Configurez l'écran de consentement OAuth si ce n'est pas déjà fait :
|
||||||
|
- Type d'application : **Interne** ou **Externe**
|
||||||
|
- Remplissez les informations requises (nom de l'application, email de support, etc.)
|
||||||
|
6. Créez l'ID client OAuth 2.0 :
|
||||||
|
- Type d'application : **Application Web**
|
||||||
|
- Nom : choisissez un nom pour votre application
|
||||||
|
- URI de redirection autorisés : ajoutez `http://localhost:3333/auth/callback` pour le développement (ou votre URL de production + `/auth/callback`)
|
||||||
|
7. Une fois créé, vous obtiendrez :
|
||||||
|
- **Client ID** : à définir dans `GOOGLE_CLIENT_ID`
|
||||||
|
- **Client Secret** : à définir dans `GOOGLE_CLIENT_SECRET`
|
||||||
|
|
||||||
|
> **Note** : Pour la production, assurez-vous d'ajouter votre URL de production dans les URI de redirection autorisés (ex: `https://votre-domaine.com/auth/callback`)
|
||||||
|
|
||||||
### GitHub Actions
|
### GitHub Actions
|
||||||
|
|
||||||
Env var to define :
|
Env var to define :
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { CollectionService } from '#collections/services/collection_service';
|
||||||
|
import { createCollectionValidator } from '#collections/validators/create_collection_validator';
|
||||||
|
import { inject } from '@adonisjs/core';
|
||||||
|
import { type HttpContext } from '@adonisjs/core/http';
|
||||||
|
|
||||||
|
@inject()
|
||||||
|
export default class CreateCollectionController {
|
||||||
|
constructor(private collectionService: CollectionService) {}
|
||||||
|
|
||||||
|
async execute({ request, response }: HttpContext) {
|
||||||
|
console.log('avant');
|
||||||
|
const payload = await request.validateUsing(createCollectionValidator);
|
||||||
|
const collection = await this.collectionService.createCollection(payload);
|
||||||
|
console.log('après', collection);
|
||||||
|
return response.json({
|
||||||
|
message: 'Collection created successfully',
|
||||||
|
collection: collection.serialize(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { CollectionService } from '#collections/services/collection_service';
|
||||||
|
import { deleteCollectionValidator } from '#collections/validators/delete_collection_validator';
|
||||||
|
import { inject } from '@adonisjs/core';
|
||||||
|
import { HttpContext } from '@adonisjs/core/http';
|
||||||
|
|
||||||
|
@inject()
|
||||||
|
export default class DeleteCollectionController {
|
||||||
|
constructor(private collectionService: CollectionService) {}
|
||||||
|
|
||||||
|
async execute({ request, response }: HttpContext) {
|
||||||
|
const { params } = await request.validateUsing(deleteCollectionValidator);
|
||||||
|
await this.collectionService.deleteCollection(params.id);
|
||||||
|
return response.json({
|
||||||
|
message: 'Collection deleted successfully',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { CollectionService } from '#collections/services/collection_service';
|
||||||
|
import { inject } from '@adonisjs/core';
|
||||||
|
import { HttpContext } from '@adonisjs/core/http';
|
||||||
|
|
||||||
|
@inject()
|
||||||
|
export default class GetCollectionsController {
|
||||||
|
constructor(private collectionService: CollectionService) {}
|
||||||
|
|
||||||
|
async show({ response }: HttpContext) {
|
||||||
|
const collections =
|
||||||
|
await this.collectionService.getCollectionsForAuthenticatedUser();
|
||||||
|
return response.json({
|
||||||
|
collections: collections.map((collection) => collection.serialize()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { CollectionService } from '#collections/services/collection_service';
|
||||||
|
import { updateCollectionValidator } from '#collections/validators/update_collection_validator';
|
||||||
|
import { inject } from '@adonisjs/core';
|
||||||
|
import { HttpContext } from '@adonisjs/core/http';
|
||||||
|
|
||||||
|
@inject()
|
||||||
|
export default class UpdateCollectionController {
|
||||||
|
constructor(private collectionService: CollectionService) {}
|
||||||
|
|
||||||
|
async execute({ request, response }: HttpContext) {
|
||||||
|
const {
|
||||||
|
params: { id: collectionId },
|
||||||
|
...payload
|
||||||
|
} = await request.validateUsing(updateCollectionValidator);
|
||||||
|
|
||||||
|
await this.collectionService.updateCollection(collectionId, payload);
|
||||||
|
return response.json({
|
||||||
|
message: 'Collection updated successfully',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/api/collections/routes/api_create_collections_routes.ts
Normal file
14
app/api/collections/routes/api_create_collections_routes.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { middleware } from '#start/kernel';
|
||||||
|
import router from '@adonisjs/core/services/router';
|
||||||
|
|
||||||
|
const CreateCollectionsController = () =>
|
||||||
|
import('#api/collections/controllers/create_collection_controller');
|
||||||
|
|
||||||
|
router
|
||||||
|
.group(() => {
|
||||||
|
router
|
||||||
|
.post('', [CreateCollectionsController, 'execute'])
|
||||||
|
.as('api-collections.create');
|
||||||
|
})
|
||||||
|
.prefix('/api/v1/collections')
|
||||||
|
.middleware([middleware.auth({ guards: ['api'] })]);
|
||||||
14
app/api/collections/routes/api_delete_collections_routes.ts
Normal file
14
app/api/collections/routes/api_delete_collections_routes.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { middleware } from '#start/kernel';
|
||||||
|
import router from '@adonisjs/core/services/router';
|
||||||
|
|
||||||
|
const DeleteCollectionsController = () =>
|
||||||
|
import('#api/collections/controllers/delete_collection_controller');
|
||||||
|
|
||||||
|
router
|
||||||
|
.group(() => {
|
||||||
|
router
|
||||||
|
.delete('/:id', [DeleteCollectionsController, 'execute'])
|
||||||
|
.as('api-collections.delete');
|
||||||
|
})
|
||||||
|
.prefix('/api/v1/collections')
|
||||||
|
.middleware([middleware.auth({ guards: ['api'] })]);
|
||||||
14
app/api/collections/routes/api_get_collections_routes.ts
Normal file
14
app/api/collections/routes/api_get_collections_routes.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { middleware } from '#start/kernel';
|
||||||
|
import router from '@adonisjs/core/services/router';
|
||||||
|
|
||||||
|
const GetCollectionsController = () =>
|
||||||
|
import('#api/collections/controllers/get_collections_controller');
|
||||||
|
|
||||||
|
router
|
||||||
|
.group(() => {
|
||||||
|
router
|
||||||
|
.get('', [GetCollectionsController, 'show'])
|
||||||
|
.as('api-collections.index');
|
||||||
|
})
|
||||||
|
.prefix('/api/v1/collections')
|
||||||
|
.middleware([middleware.auth({ guards: ['api'] })]);
|
||||||
14
app/api/collections/routes/api_update_collections_routes.ts
Normal file
14
app/api/collections/routes/api_update_collections_routes.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { middleware } from '#start/kernel';
|
||||||
|
import router from '@adonisjs/core/services/router';
|
||||||
|
|
||||||
|
const UpdateCollectionsController = () =>
|
||||||
|
import('#api/collections/controllers/update_collection_controller');
|
||||||
|
|
||||||
|
router
|
||||||
|
.group(() => {
|
||||||
|
router
|
||||||
|
.put('/:id', [UpdateCollectionsController, 'execute'])
|
||||||
|
.as('api-collections.update');
|
||||||
|
})
|
||||||
|
.prefix('/api/v1/collections')
|
||||||
|
.middleware([middleware.auth({ guards: ['api'] })]);
|
||||||
4
app/api/collections/routes/routes.ts
Normal file
4
app/api/collections/routes/routes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import '#api/collections/routes/api_create_collections_routes';
|
||||||
|
import '#api/collections/routes/api_delete_collections_routes';
|
||||||
|
import '#api/collections/routes/api_get_collections_routes';
|
||||||
|
import '#api/collections/routes/api_update_collections_routes';
|
||||||
23
app/api/links/controllers/create_link_controller.ts
Normal file
23
app/api/links/controllers/create_link_controller.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { LinkService } from '#links/services/link_service';
|
||||||
|
import { createLinkValidator } from '#links/validators/create_link_validator';
|
||||||
|
import { inject } from '@adonisjs/core';
|
||||||
|
import { HttpContext } from '@adonisjs/core/http';
|
||||||
|
|
||||||
|
@inject()
|
||||||
|
export default class CreateLinkController {
|
||||||
|
constructor(private linkService: LinkService) {}
|
||||||
|
|
||||||
|
async execute({ request, response }: HttpContext) {
|
||||||
|
const { collectionId, ...payload } =
|
||||||
|
await request.validateUsing(createLinkValidator);
|
||||||
|
|
||||||
|
const link = await this.linkService.createLink({
|
||||||
|
...payload,
|
||||||
|
collectionId,
|
||||||
|
});
|
||||||
|
return response.json({
|
||||||
|
message: 'Link created successfully',
|
||||||
|
link: link.serialize(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/api/links/controllers/delete_link_controller.ts
Normal file
39
app/api/links/controllers/delete_link_controller.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { CollectionService } from '#collections/services/collection_service';
|
||||||
|
import { LinkService } from '#links/services/link_service';
|
||||||
|
import { deleteLinkValidator } from '#links/validators/delete_link_validator';
|
||||||
|
import { inject } from '@adonisjs/core';
|
||||||
|
import { HttpContext } from '@adonisjs/core/http';
|
||||||
|
import db from '@adonisjs/lucid/services/db';
|
||||||
|
|
||||||
|
@inject()
|
||||||
|
export default class DeleteLinkController {
|
||||||
|
constructor(
|
||||||
|
protected collectionsService: CollectionService,
|
||||||
|
protected linkService: LinkService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async render({ auth, inertia, request }: HttpContext) {
|
||||||
|
const linkId = request.qs()?.linkId;
|
||||||
|
if (!linkId) {
|
||||||
|
return this.collectionsService.redirectToDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = await this.linkService.getLinkById(linkId, auth.user!.id);
|
||||||
|
await link.load('collection');
|
||||||
|
return inertia.render('links/delete', { link });
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute({ request, auth }: HttpContext) {
|
||||||
|
const { params } = await request.validateUsing(deleteLinkValidator);
|
||||||
|
|
||||||
|
const link = await this.linkService.getLinkById(params.id, auth.user!.id);
|
||||||
|
await this.linkService.deleteLink(params.id);
|
||||||
|
|
||||||
|
return this.collectionsService.redirectToCollectionId(link.collectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTotalLinksCount() {
|
||||||
|
const totalCount = await db.from('links').count('* as total');
|
||||||
|
return Number(totalCount[0].total);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/api/links/controllers/get_favorite_links_controller.ts
Normal file
13
app/api/links/controllers/get_favorite_links_controller.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { LinkService } from '#links/services/link_service';
|
||||||
|
import { inject } from '@adonisjs/core';
|
||||||
|
import { HttpContext } from '@adonisjs/core/http';
|
||||||
|
|
||||||
|
@inject()
|
||||||
|
export default class GetFavoriteLinksController {
|
||||||
|
constructor(private linkService: LinkService) {}
|
||||||
|
|
||||||
|
public async execute({ response }: HttpContext) {
|
||||||
|
const links = await this.linkService.getFavoriteLinksForAuthenticatedUser();
|
||||||
|
return response.json(links);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/api/links/controllers/update_link_controller.ts
Normal file
19
app/api/links/controllers/update_link_controller.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { LinkService } from '#links/services/link_service';
|
||||||
|
import { updateLinkValidator } from '#links/validators/update_link_validator';
|
||||||
|
import { inject } from '@adonisjs/core';
|
||||||
|
import { HttpContext } from '@adonisjs/core/http';
|
||||||
|
|
||||||
|
@inject()
|
||||||
|
export default class UpdateLinkController {
|
||||||
|
constructor(private linkService: LinkService) {}
|
||||||
|
|
||||||
|
async execute({ request, response }: HttpContext) {
|
||||||
|
const { params, ...payload } =
|
||||||
|
await request.validateUsing(updateLinkValidator);
|
||||||
|
|
||||||
|
await this.linkService.updateLink(params.id, payload);
|
||||||
|
return response.json({
|
||||||
|
message: 'Link updated successfully',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/api/links/routes/api_create_link_routes.ts
Normal file
12
app/api/links/routes/api_create_link_routes.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { middleware } from '#start/kernel';
|
||||||
|
import router from '@adonisjs/core/services/router';
|
||||||
|
|
||||||
|
const CreateLinkController = () =>
|
||||||
|
import('#api/links/controllers/create_link_controller');
|
||||||
|
|
||||||
|
router
|
||||||
|
.group(() => {
|
||||||
|
router.post('', [CreateLinkController, 'execute']).as('api-links.create');
|
||||||
|
})
|
||||||
|
.prefix('/api/v1/links')
|
||||||
|
.middleware([middleware.auth({ guards: ['api'] })]);
|
||||||
14
app/api/links/routes/api_delete_link_route.ts
Normal file
14
app/api/links/routes/api_delete_link_route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { middleware } from '#start/kernel';
|
||||||
|
import router from '@adonisjs/core/services/router';
|
||||||
|
|
||||||
|
const DeleteLinkController = () =>
|
||||||
|
import('#api/links/controllers/delete_link_controller');
|
||||||
|
|
||||||
|
router
|
||||||
|
.group(() => {
|
||||||
|
router
|
||||||
|
.delete('/:id', [DeleteLinkController, 'execute'])
|
||||||
|
.as('api-links.delete');
|
||||||
|
})
|
||||||
|
.prefix('/api/v1/links')
|
||||||
|
.middleware([middleware.auth({ guards: ['api'] })]);
|
||||||
14
app/api/links/routes/api_get_favorite_links_routes.ts
Normal file
14
app/api/links/routes/api_get_favorite_links_routes.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { middleware } from '#start/kernel';
|
||||||
|
import router from '@adonisjs/core/services/router';
|
||||||
|
|
||||||
|
const GetFavoriteLinksController = () =>
|
||||||
|
import('#api/links/controllers/get_favorite_links_controller');
|
||||||
|
|
||||||
|
router
|
||||||
|
.group(() => {
|
||||||
|
router
|
||||||
|
.get('', [GetFavoriteLinksController, 'execute'])
|
||||||
|
.as('api-links.get-favorite-links');
|
||||||
|
})
|
||||||
|
.prefix('/api/v1/links/favorites')
|
||||||
|
.middleware([middleware.auth({ guards: ['api'] })]);
|
||||||
14
app/api/links/routes/api_update_link_route.ts
Normal file
14
app/api/links/routes/api_update_link_route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { middleware } from '#start/kernel';
|
||||||
|
import router from '@adonisjs/core/services/router';
|
||||||
|
|
||||||
|
const UpdateLinkController = () =>
|
||||||
|
import('#api/links/controllers/update_link_controller');
|
||||||
|
|
||||||
|
router
|
||||||
|
.group(() => {
|
||||||
|
router
|
||||||
|
.put('/:id', [UpdateLinkController, 'execute'])
|
||||||
|
.as('api-links.update');
|
||||||
|
})
|
||||||
|
.prefix('/api/v1/links')
|
||||||
|
.middleware([middleware.auth({ guards: ['api'] })]);
|
||||||
4
app/api/links/routes/routes.ts
Normal file
4
app/api/links/routes/routes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import '#api/links/routes/api_create_link_routes';
|
||||||
|
import '#api/links/routes/api_delete_link_route';
|
||||||
|
import '#api/links/routes/api_get_favorite_links_routes';
|
||||||
|
import '#api/links/routes/api_update_link_route';
|
||||||
18
app/api/tokens/controllers/api_token_controller.ts
Normal file
18
app/api/tokens/controllers/api_token_controller.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import UnAuthorizedException from '#api/tokens/exceptions/un_authorized_exception';
|
||||||
|
import { getTokenFromHeader } from '#api/tokens/lib/index';
|
||||||
|
import { inject } from '@adonisjs/core';
|
||||||
|
import { HttpContext } from '@adonisjs/core/http';
|
||||||
|
|
||||||
|
@inject()
|
||||||
|
export default class ApiTokenController {
|
||||||
|
async index(ctx: HttpContext) {
|
||||||
|
const token = getTokenFromHeader(ctx);
|
||||||
|
if (!token) {
|
||||||
|
throw new UnAuthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.response.json({
|
||||||
|
message: 'Token is valid',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/api/tokens/exceptions/un_authorized_exception.ts
Normal file
7
app/api/tokens/exceptions/un_authorized_exception.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Exception } from '@adonisjs/core/exceptions';
|
||||||
|
|
||||||
|
export default class UnAuthorizedException extends Exception {
|
||||||
|
static status = 401;
|
||||||
|
message = 'Missing or invalid authorization header';
|
||||||
|
code = 'UNAUTHORIZED';
|
||||||
|
}
|
||||||
11
app/api/tokens/lib/index.ts
Normal file
11
app/api/tokens/lib/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { HttpContext } from '@adonisjs/core/http';
|
||||||
|
|
||||||
|
export function getTokenFromHeader(ctx: HttpContext) {
|
||||||
|
const authHeader = ctx.request.header('Authorization');
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return authHeader.substring(7);
|
||||||
|
}
|
||||||
12
app/api/tokens/routes/api_token_routes.ts
Normal file
12
app/api/tokens/routes/api_token_routes.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { middleware } from '#start/kernel';
|
||||||
|
import router from '@adonisjs/core/services/router';
|
||||||
|
|
||||||
|
const ApiTokenController = () =>
|
||||||
|
import('#api/tokens/controllers/api_token_controller');
|
||||||
|
|
||||||
|
router
|
||||||
|
.group(() => {
|
||||||
|
router.get('/check', [ApiTokenController, 'index']).as('api-tokens.index');
|
||||||
|
})
|
||||||
|
.prefix('/api/v1/tokens')
|
||||||
|
.middleware([middleware.auth({ guards: ['api'] })]);
|
||||||
1
app/api/tokens/routes/routes.ts
Normal file
1
app/api/tokens/routes/routes.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '#api/tokens/routes/api_token_routes';
|
||||||
@@ -36,6 +36,13 @@ export default class HttpExceptionHandler extends ExceptionHandler {
|
|||||||
* response to the client
|
* response to the client
|
||||||
*/
|
*/
|
||||||
async handle(error: unknown, ctx: HttpContext) {
|
async handle(error: unknown, ctx: HttpContext) {
|
||||||
|
if (ctx.request.url()?.startsWith('/api/v1')) {
|
||||||
|
return ctx.response.status(400).json({
|
||||||
|
message: 'Bad Request',
|
||||||
|
errors: [error],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (error instanceof errors.E_ROW_NOT_FOUND) {
|
if (error instanceof errors.E_ROW_NOT_FOUND) {
|
||||||
return ctx.response.redirectToNamedRoute('dashboard');
|
return ctx.response.redirectToNamedRoute('dashboard');
|
||||||
}
|
}
|
||||||
|
|||||||
9
app/links/validators/base_link_validator.ts
Normal file
9
app/links/validators/base_link_validator.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import vine from '@vinejs/vine';
|
||||||
|
|
||||||
|
export const baseLinkValidator = vine.object({
|
||||||
|
name: vine.string().trim().minLength(1).maxLength(254),
|
||||||
|
description: vine.string().trim().maxLength(300).optional(),
|
||||||
|
url: vine.string().url({ require_tld: false }).trim(),
|
||||||
|
favorite: vine.boolean(),
|
||||||
|
collectionId: vine.number(),
|
||||||
|
});
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
|
import { baseLinkValidator } from '#links/validators/base_link_validator';
|
||||||
import vine from '@vinejs/vine';
|
import vine from '@vinejs/vine';
|
||||||
|
|
||||||
export const createLinkValidator = vine.compile(
|
export const createLinkValidator = vine.compile(
|
||||||
vine.object({
|
vine.object({
|
||||||
name: vine.string().trim().minLength(1).maxLength(254),
|
...baseLinkValidator.getProperties(),
|
||||||
description: vine.string().trim().maxLength(300).optional(),
|
|
||||||
url: vine.string().trim(),
|
|
||||||
favorite: vine.boolean(),
|
|
||||||
collectionId: vine.number(),
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { params } from '#core/validators/params_object';
|
import { params } from '#core/validators/params_object';
|
||||||
|
import { baseLinkValidator } from '#links/validators/base_link_validator';
|
||||||
import vine from '@vinejs/vine';
|
import vine from '@vinejs/vine';
|
||||||
|
|
||||||
export const updateLinkValidator = vine.compile(
|
export const updateLinkValidator = vine.compile(
|
||||||
vine.object({
|
vine.object({
|
||||||
name: vine.string().trim().minLength(1).maxLength(254),
|
...baseLinkValidator.getProperties(),
|
||||||
description: vine.string().trim().maxLength(300).optional(),
|
|
||||||
url: vine.string().trim(),
|
|
||||||
favorite: vine.boolean(),
|
|
||||||
collectionId: vine.number(),
|
|
||||||
|
|
||||||
params,
|
params,
|
||||||
})
|
})
|
||||||
|
|||||||
45
app/user/controllers/api_token_controller.ts
Normal file
45
app/user/controllers/api_token_controller.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@inject()
|
||||||
|
export default class ApiTokenController {
|
||||||
|
constructor(private apiTokenService: ApiTokenService) {}
|
||||||
|
|
||||||
|
async store({ request, response, auth, session }: HttpContext) {
|
||||||
|
const { name, expiresAt } = await request.validateUsing(
|
||||||
|
createApiTokenValidator
|
||||||
|
);
|
||||||
|
const token = await this.apiTokenService.createToken(auth.user!, {
|
||||||
|
name,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
session.flash('token', {
|
||||||
|
...token.toJSON(),
|
||||||
|
token: token.value?.release(),
|
||||||
|
identifier: token.identifier,
|
||||||
|
});
|
||||||
|
return response.redirect().withQs().back();
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy({ request, response, auth }: HttpContext) {
|
||||||
|
const { params } = await request.validateUsing(deleteApiTokenValidator);
|
||||||
|
const tokenId = params.tokenId;
|
||||||
|
|
||||||
|
const token = await this.apiTokenService.getTokenByValue(
|
||||||
|
auth.user!,
|
||||||
|
tokenId
|
||||||
|
);
|
||||||
|
if (!token) {
|
||||||
|
return response.notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.apiTokenService.revokeToken(
|
||||||
|
auth.user!,
|
||||||
|
Number(token.identifier)
|
||||||
|
);
|
||||||
|
return response.redirect().withQs().back();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import Link from '#links/models/link';
|
|||||||
import { type DisplayPreferences } from '#shared/types/index';
|
import { type DisplayPreferences } from '#shared/types/index';
|
||||||
import { ensureDisplayPreferences } from '#user/lib/index';
|
import { ensureDisplayPreferences } from '#user/lib/index';
|
||||||
import type { GoogleToken } from '@adonisjs/ally/types';
|
import type { GoogleToken } from '@adonisjs/ally/types';
|
||||||
|
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens';
|
||||||
import { column, computed, hasMany } from '@adonisjs/lucid/orm';
|
import { column, computed, hasMany } from '@adonisjs/lucid/orm';
|
||||||
import type { HasMany } from '@adonisjs/lucid/types/relations';
|
import type { HasMany } from '@adonisjs/lucid/types/relations';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@@ -64,4 +65,6 @@ export default class User extends AppBaseModel {
|
|||||||
prepare: (value) => JSON.stringify(value),
|
prepare: (value) => JSON.stringify(value),
|
||||||
})
|
})
|
||||||
declare displayPreferences: DisplayPreferences;
|
declare displayPreferences: DisplayPreferences;
|
||||||
|
|
||||||
|
static accessTokens = DbAccessTokensProvider.forModel(User);
|
||||||
}
|
}
|
||||||
|
|||||||
15
app/user/routes/api_token_routes.ts
Normal file
15
app/user/routes/api_token_routes.ts
Normal 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()]);
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
import './api_token_routes.js';
|
||||||
import './user_display_preferences_route.js';
|
import './user_display_preferences_route.js';
|
||||||
import './user_theme_route.js';
|
import './user_theme_route.js';
|
||||||
|
|||||||
28
app/user/services/api_token_service.ts
Normal file
28
app/user/services/api_token_service.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import User from '#user/models/user';
|
||||||
|
|
||||||
|
type CreateTokenParams = {
|
||||||
|
name: string;
|
||||||
|
expiresAt?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ApiTokenService {
|
||||||
|
createToken(user: User, { name, expiresAt }: CreateTokenParams) {
|
||||||
|
const expiresIn = expiresAt ? expiresAt.getTime() - Date.now() : undefined;
|
||||||
|
return User.accessTokens.create(user, undefined, {
|
||||||
|
name,
|
||||||
|
expiresIn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getTokens(user: User) {
|
||||||
|
return User.accessTokens.all(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeToken(user: User, identifier: number) {
|
||||||
|
return User.accessTokens.delete(user, identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTokenByValue(user: User, value: string) {
|
||||||
|
return User.accessTokens.find(user, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/user/validators/token/create_api_token.ts
Normal file
8
app/user/validators/token/create_api_token.ts
Normal 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(),
|
||||||
|
})
|
||||||
|
);
|
||||||
9
app/user/validators/token/delete_api_token.ts
Normal file
9
app/user/validators/token/delete_api_token.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import vine from '@vinejs/vine';
|
||||||
|
|
||||||
|
export const deleteApiTokenValidator = vine.compile(
|
||||||
|
vine.object({
|
||||||
|
params: vine.object({
|
||||||
|
tokenId: vine.string().trim().minLength(1).maxLength(255),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
|
import { ApiTokenService } from '#user/services/api_token_service';
|
||||||
|
import { inject } from '@adonisjs/core';
|
||||||
import { HttpContext } from '@adonisjs/core/http';
|
import { HttpContext } from '@adonisjs/core/http';
|
||||||
|
|
||||||
|
@inject()
|
||||||
export default class ShowUserSettingsController {
|
export default class ShowUserSettingsController {
|
||||||
|
constructor(private apiTokenService: ApiTokenService) {}
|
||||||
|
|
||||||
public async render({ auth, inertia }: HttpContext) {
|
public async render({ auth, inertia }: HttpContext) {
|
||||||
const user = await auth.authenticate();
|
const user = await auth.authenticate();
|
||||||
|
const tokens = await this.apiTokenService.getTokens(user);
|
||||||
return inertia.render('user_settings/show', {
|
return inertia.render('user_settings/show', {
|
||||||
user,
|
user,
|
||||||
|
tokens: tokens.map((token) => {
|
||||||
|
return {
|
||||||
|
...token.toJSON(),
|
||||||
|
identifier: token.identifier,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from '@adonisjs/auth';
|
import { defineConfig } from '@adonisjs/auth';
|
||||||
|
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens';
|
||||||
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session';
|
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session';
|
||||||
import { Authenticators, InferAuthEvents } from '@adonisjs/auth/types';
|
import { Authenticators, InferAuthEvents } from '@adonisjs/auth/types';
|
||||||
|
|
||||||
@@ -11,6 +12,12 @@ const authConfig = defineConfig({
|
|||||||
model: () => import('#user/models/user'),
|
model: () => import('#user/models/user'),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
api: tokensGuard({
|
||||||
|
provider: tokensUserProvider({
|
||||||
|
tokens: 'accessTokens',
|
||||||
|
model: () => import('#user/models/user'),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
|||||||
*/
|
*/
|
||||||
sharedData: {
|
sharedData: {
|
||||||
errors: (ctx) => ctx.session?.flashMessages.get('errors'),
|
errors: (ctx) => ctx.session?.flashMessages.get('errors'),
|
||||||
|
token: (ctx) => ctx.session?.flashMessages.get('token'),
|
||||||
user: (ctx) => ({
|
user: (ctx) => ({
|
||||||
theme: ctx.session?.get(KEY_USER_THEME, DEFAULT_USER_THEME),
|
theme: ctx.session?.get(KEY_USER_THEME, DEFAULT_USER_THEME),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { BaseSchema } from '@adonisjs/lucid/schema';
|
||||||
|
|
||||||
|
export default class CreateAuthAccessTokensTable extends BaseSchema {
|
||||||
|
protected tableName = 'auth_access_tokens';
|
||||||
|
|
||||||
|
async up() {
|
||||||
|
this.schema.createTable(this.tableName, (table) => {
|
||||||
|
table.increments('id');
|
||||||
|
table
|
||||||
|
.integer('tokenable_id')
|
||||||
|
.notNullable()
|
||||||
|
.unsigned()
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
|
||||||
|
table.string('type').notNullable();
|
||||||
|
table.string('name').nullable();
|
||||||
|
table.string('hash').notNullable();
|
||||||
|
table.text('abilities').notNullable();
|
||||||
|
table.timestamp('created_at');
|
||||||
|
table.timestamp('updated_at');
|
||||||
|
table.timestamp('last_used_at').nullable();
|
||||||
|
table.timestamp('expires_at').nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async down() {
|
||||||
|
this.schema.dropTable(this.tableName);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
inertia/components/common/api_tokens/api_tokens.tsx
Normal file
128
inertia/components/common/api_tokens/api_tokens.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { usePage } from '@inertiajs/react';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CopyButton,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { modals } from '@mantine/modals';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { TbPlus, TbTrash } from 'react-icons/tb';
|
||||||
|
import { SimpleTable } from '~/components/common/simple_table/simple_table';
|
||||||
|
import { useApiTokens } from '~/hooks/use_api_tokens';
|
||||||
|
import { ApiToken } from '~/types/app';
|
||||||
|
import { CreateTokenModal } from './create_token_modal';
|
||||||
|
|
||||||
|
const useGetCreatedToken = () => {
|
||||||
|
const newlyCreatedToken = usePage<{
|
||||||
|
token?: ApiToken;
|
||||||
|
}>().props.token;
|
||||||
|
return newlyCreatedToken;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ApiTokens() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { tokens, createToken, revokeToken } = useApiTokens();
|
||||||
|
|
||||||
|
const newlyCreatedToken = useGetCreatedToken();
|
||||||
|
|
||||||
|
const handleCreateTokenModal = () => {
|
||||||
|
modals.open({
|
||||||
|
title: t('api-tokens.create-new'),
|
||||||
|
children: (
|
||||||
|
<CreateTokenModal
|
||||||
|
onCreate={(name) => createToken(name)}
|
||||||
|
onClose={() => modals.closeAll()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeToken = async (tokenId: number) => {
|
||||||
|
const token = tokens.find((t) => t.identifier === tokenId);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: (
|
||||||
|
<>
|
||||||
|
{t('api-tokens.revoke')} "<strong>{token.name}</strong>"
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
children: <Text size="sm">{t('api-tokens.confirm-revoke')}</Text>,
|
||||||
|
labels: {
|
||||||
|
confirm: t('api-tokens.revoke'),
|
||||||
|
cancel: t('cancel'),
|
||||||
|
},
|
||||||
|
confirmProps: { color: 'red' },
|
||||||
|
onConfirm: () => revokeToken(tokenId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateTokenRow = (token: ApiToken) =>
|
||||||
|
newlyCreatedToken?.identifier === token.identifier && (
|
||||||
|
<>
|
||||||
|
<Text c="green" size="sm">
|
||||||
|
{t('api-tokens.new-token')}{' '}
|
||||||
|
{newlyCreatedToken.token && (
|
||||||
|
<CopyButton value={newlyCreatedToken.token}>
|
||||||
|
{({ copied, copy }) => (
|
||||||
|
<Button
|
||||||
|
color={copied ? 'teal' : 'blue'}
|
||||||
|
onClick={copy}
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
{copied ? t('copied') : t('copy')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CopyButton>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateRow = (token: ApiToken) => ({
|
||||||
|
key: token.identifier.toString(),
|
||||||
|
name: token.name,
|
||||||
|
token: generateTokenRow(token) || undefined,
|
||||||
|
expiresAt: token.expiresAt,
|
||||||
|
lastUsedAt: token.lastUsedAt,
|
||||||
|
actions: [
|
||||||
|
<ActionIcon
|
||||||
|
color="red"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => handleRevokeToken(token.identifier)}
|
||||||
|
>
|
||||||
|
<TbTrash size={16} />
|
||||||
|
</ActionIcon>,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = tokens.map(generateRow);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder>
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Text fw={500}>{t('api-tokens.title')}</Text>
|
||||||
|
<Button
|
||||||
|
leftSection={<TbPlus size={16} />}
|
||||||
|
onClick={handleCreateTokenModal}
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
{t('api-tokens.create')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{tokens.length === 0 && (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
{t('api-tokens.no-tokens')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tokens.length > 0 && <SimpleTable data={rows} />}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
inertia/components/common/api_tokens/create_token_modal.tsx
Normal file
53
inertia/components/common/api_tokens/create_token_modal.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Button, Group, Stack, Text, TextInput } from '@mantine/core';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface CreateTokenModalProps {
|
||||||
|
onCreate: (name: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateTokenModal({ onCreate, onClose }: CreateTokenModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [tokenName, setTokenName] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!tokenName.trim()) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await onCreate(tokenName);
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('api-tokens.create-description')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
label={t('api-tokens.name')}
|
||||||
|
placeholder={t('api-tokens.name-placeholder')}
|
||||||
|
value={tokenName}
|
||||||
|
onChange={(e) => setTokenName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="subtle" onClick={onClose}>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!tokenName.trim() || isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{t('api-tokens.create')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ export function UserDropdown() {
|
|||||||
alt={auth.user?.fullname}
|
alt={auth.user?.fullname}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
size={20}
|
size={20}
|
||||||
|
imageProps={{
|
||||||
|
referrerPolicy: 'no-referrer',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Text fw={500} size="sm" lh={1} mr={3}>
|
<Text fw={500} size="sm" lh={1} mr={3}>
|
||||||
{auth.user?.fullname}
|
{auth.user?.fullname}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
.header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: var(--mantine-color-body);
|
||||||
|
transition: box-shadow 150ms ease;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-bottom: 1px solid
|
||||||
|
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrolled {
|
||||||
|
box-shadow: var(--mantine-shadow-sm);
|
||||||
|
}
|
||||||
56
inertia/components/common/simple_table/simple_table.tsx
Normal file
56
inertia/components/common/simple_table/simple_table.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { ScrollArea, Table, Text } from '@mantine/core';
|
||||||
|
import cx from 'clsx';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import classes from './simple_table.module.css';
|
||||||
|
|
||||||
|
export type SimpleTableData = {
|
||||||
|
key: string;
|
||||||
|
[key: string]: string | React.ReactNode | undefined;
|
||||||
|
actions?: React.ReactNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SimpleTableProps {
|
||||||
|
data: SimpleTableData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleTable({ data }: SimpleTableProps) {
|
||||||
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
|
||||||
|
const columns = data.length > 0 ? Object.keys(data[0]) : [];
|
||||||
|
|
||||||
|
const rows = data.map((row) => {
|
||||||
|
return (
|
||||||
|
<Table.Tr key={row.key}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<Table.Td key={column}>
|
||||||
|
{row[column] ?? (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
N/A
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea
|
||||||
|
h={300}
|
||||||
|
onScrollPositionChange={({ y }) => setScrolled(y !== 0)}
|
||||||
|
>
|
||||||
|
<Table miw={700}>
|
||||||
|
<Table.Thead
|
||||||
|
className={cx(classes.header, { [classes.scrolled]: scrolled })}
|
||||||
|
>
|
||||||
|
<Table.Tr>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<Table.Th key={column}>{column}</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>{rows}</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ interface FormCollectionProps extends FormLayoutProps {
|
|||||||
handleSubmit: () => void;
|
handleSubmit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MantineFormCollection({
|
export function FormCollection({
|
||||||
data,
|
data,
|
||||||
errors,
|
errors,
|
||||||
disableInputs = false,
|
disableInputs = false,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface FormLinkProps extends FormLayoutProps {
|
|||||||
handleSubmit: () => void;
|
handleSubmit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MantineFormLink({
|
export function FormLink({
|
||||||
data,
|
data,
|
||||||
errors,
|
errors,
|
||||||
collections,
|
collections,
|
||||||
@@ -83,7 +83,7 @@ export default function MantineFormLink({
|
|||||||
value: id.toString(),
|
value: id.toString(),
|
||||||
}))}
|
}))}
|
||||||
onChange={(value) => setData('collectionId', value)}
|
onChange={(value) => setData('collectionId', value)}
|
||||||
value={data.collectionId.toString()}
|
value={data.collectionId?.toString()}
|
||||||
readOnly={disableInputs}
|
readOnly={disableInputs}
|
||||||
mt="md"
|
mt="md"
|
||||||
searchable
|
searchable
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
.header {
|
|
||||||
height: rem(60px);
|
|
||||||
border-bottom: rem(1px) solid
|
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 100%;
|
|
||||||
padding-left: var(--mantine-spacing-md);
|
|
||||||
padding-right: var(--mantine-spacing-md);
|
|
||||||
text-decoration: none;
|
|
||||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: var(--mantine-font-size-sm);
|
|
||||||
|
|
||||||
@media (max-width: $mantine-breakpoint-sm) {
|
|
||||||
height: rem(42px);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin hover {
|
|
||||||
background-color: light-dark(
|
|
||||||
var(--mantine-color-gray-0),
|
|
||||||
var(--mantine-color-dark-6)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import PATHS from '#core/constants/paths';
|
|
||||||
import { Link } from '@inertiajs/react';
|
|
||||||
import { route } from '@izzyjs/route/client';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Burger,
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
Drawer,
|
|
||||||
Group,
|
|
||||||
Image,
|
|
||||||
ScrollArea,
|
|
||||||
rem,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import ExternalLink from '~/components/common/external_link';
|
|
||||||
import { LocaleSwitcher } from '~/components/common/locale_switcher';
|
|
||||||
import { ThemeSwitcher } from '~/components/common/theme_switcher';
|
|
||||||
import useUser from '~/hooks/use_auth';
|
|
||||||
import classes from './mobile.module.css';
|
|
||||||
|
|
||||||
export default function Navbar() {
|
|
||||||
const { t } = useTranslation('common');
|
|
||||||
const { isAuthenticated } = useUser();
|
|
||||||
const [drawerOpened, { toggle: toggleDrawer, close: closeDrawer }] =
|
|
||||||
useDisclosure(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box pb={40}>
|
|
||||||
<header className={classes.header}>
|
|
||||||
<Group justify="space-between" h="100%">
|
|
||||||
<Link href="/">
|
|
||||||
<Image src="/logo.png" h={35} alt="MyLinks's logo" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Group h="100%" gap={0} visibleFrom="sm">
|
|
||||||
<Link href="/" className={classes.link}>
|
|
||||||
{t('home')}
|
|
||||||
</Link>
|
|
||||||
<ExternalLink href={PATHS.REPO_GITHUB} className={classes.link}>
|
|
||||||
Github
|
|
||||||
</ExternalLink>
|
|
||||||
<ExternalLink href={PATHS.EXTENSION} className={classes.link}>
|
|
||||||
Extension
|
|
||||||
</ExternalLink>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap="xs">
|
|
||||||
<ThemeSwitcher />
|
|
||||||
<LocaleSwitcher />
|
|
||||||
{!isAuthenticated ? (
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href={route('auth').path}
|
|
||||||
visibleFrom="sm"
|
|
||||||
w={110}
|
|
||||||
>
|
|
||||||
{t('login')}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
component={Link}
|
|
||||||
href={route('dashboard').path}
|
|
||||||
visibleFrom="sm"
|
|
||||||
w={110}
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Burger
|
|
||||||
opened={drawerOpened}
|
|
||||||
onClick={toggleDrawer}
|
|
||||||
hiddenFrom="sm"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<Drawer
|
|
||||||
opened={drawerOpened}
|
|
||||||
onClose={closeDrawer}
|
|
||||||
size="100%"
|
|
||||||
padding="md"
|
|
||||||
title="Navigation"
|
|
||||||
hiddenFrom="sm"
|
|
||||||
zIndex={1000000}
|
|
||||||
>
|
|
||||||
<ScrollArea h={`calc(100vh - ${rem(80)})`} mx="-md">
|
|
||||||
<Divider my="sm" />
|
|
||||||
|
|
||||||
<Link href="#" className={classes.link}>
|
|
||||||
{t('home')}
|
|
||||||
</Link>
|
|
||||||
<ExternalLink href={PATHS.REPO_GITHUB} className={classes.link}>
|
|
||||||
Github
|
|
||||||
</ExternalLink>
|
|
||||||
<ExternalLink href={PATHS.EXTENSION} className={classes.link}>
|
|
||||||
Extension
|
|
||||||
</ExternalLink>
|
|
||||||
|
|
||||||
<Divider my="sm" />
|
|
||||||
|
|
||||||
<Group justify="center" grow pb="xl" px="md">
|
|
||||||
{!isAuthenticated ? (
|
|
||||||
<Button component="a" href={route('auth').path} w={110}>
|
|
||||||
{t('login')}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button component={Link} href={route('dashboard').path} w={110}>
|
|
||||||
Dashboard
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</ScrollArea>
|
|
||||||
</Drawer>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
24
inertia/hooks/use_api_tokens.ts
Normal file
24
inertia/hooks/use_api_tokens.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { router, usePage } from '@inertiajs/react';
|
||||||
|
import { ApiToken } from '~/types/app';
|
||||||
|
|
||||||
|
export function useApiTokens() {
|
||||||
|
const {
|
||||||
|
props: { tokens },
|
||||||
|
} = usePage<{
|
||||||
|
tokens: ApiToken[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const createToken = async (name: string, expiresAt?: Date) => {
|
||||||
|
return router.post('/user/api-tokens', { name, expiresAt });
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeToken = async (tokenId: number) => {
|
||||||
|
return router.delete(`/user/api-tokens/${tokenId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokens,
|
||||||
|
createToken,
|
||||||
|
revokeToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -94,5 +94,27 @@
|
|||||||
"list": "List",
|
"list": "List",
|
||||||
"grid": "Grid"
|
"grid": "Grid"
|
||||||
},
|
},
|
||||||
"coming-soon": "Under development"
|
"coming-soon": "Under development",
|
||||||
|
"api-tokens": {
|
||||||
|
"title": "API Tokens",
|
||||||
|
"create": "Create token",
|
||||||
|
"create-new": "Create new token",
|
||||||
|
"create-description": "This token will be used for browser extension authentication.",
|
||||||
|
"name": "Token name",
|
||||||
|
"name-placeholder": "Ex: Chrome Extension",
|
||||||
|
"token-created": "Token created successfully",
|
||||||
|
"token-created-description": "Copy this token and keep it safe. It will not be displayed again after closing this window.",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"confirm-revoke": "Are you sure you want to revoke this token?",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied",
|
||||||
|
"no-tokens": "No tokens created",
|
||||||
|
"expired": "Expired",
|
||||||
|
"revoked": "Revoked",
|
||||||
|
"created": "Created",
|
||||||
|
"last-used": "Last used",
|
||||||
|
"expires": "Expires",
|
||||||
|
"never": "Never",
|
||||||
|
"new-token": "New token created"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,5 +94,27 @@
|
|||||||
"list": "Liste",
|
"list": "Liste",
|
||||||
"grid": "Grille"
|
"grid": "Grille"
|
||||||
},
|
},
|
||||||
"coming-soon": "En cours de développement"
|
"coming-soon": "En cours de développement",
|
||||||
|
"api-tokens": {
|
||||||
|
"title": "Tokens API",
|
||||||
|
"create": "Créer un token",
|
||||||
|
"create-new": "Créer un nouveau token",
|
||||||
|
"create-description": "Ce token sera utilisé pour l'authentification de votre extension navigateur.",
|
||||||
|
"name": "Nom du token",
|
||||||
|
"name-placeholder": "Ex: Extension Chrome",
|
||||||
|
"token-created": "Token créé avec succès",
|
||||||
|
"token-created-description": "Copiez ce token et conservez-le en lieu sûr. Il ne sera plus affiché après fermeture de cette fenêtre.",
|
||||||
|
"revoke": "Révoquer",
|
||||||
|
"confirm-revoke": "Êtes-vous sûr de vouloir révoquer ce token ?",
|
||||||
|
"copy": "Copier",
|
||||||
|
"copied": "Copié",
|
||||||
|
"no-tokens": "Aucun token créé",
|
||||||
|
"expired": "Expiré",
|
||||||
|
"revoked": "Révoqué",
|
||||||
|
"created": "Créé",
|
||||||
|
"last-used": "Dernière utilisation",
|
||||||
|
"expires": "Expire",
|
||||||
|
"never": "Jamais",
|
||||||
|
"new-token": "Nouveau token créé"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,15 +16,50 @@ export const appendResourceId = (
|
|||||||
) => `${url}${resourceId ? `/${resourceId}` : ''}`;
|
) => `${url}${resourceId ? `/${resourceId}` : ''}`;
|
||||||
|
|
||||||
export function isValidHttpUrl(urlParam: string) {
|
export function isValidHttpUrl(urlParam: string) {
|
||||||
let url;
|
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}(:\d+)?(\/.*)?(\?.*)?(#[^#]*)?$/;
|
||||||
|
const domainRegex =
|
||||||
|
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(:\d+)?(\/.*)?(\?.*)?(#[^#]*)?$/;
|
||||||
|
const simpleDomainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$/;
|
||||||
|
|
||||||
try {
|
let urlToTest = urlParam.trim();
|
||||||
url = new URL(urlParam);
|
|
||||||
} catch (_) {
|
if (urlToTest.startsWith('http://') || urlToTest.startsWith('https://')) {
|
||||||
return false;
|
try {
|
||||||
|
const url = new URL(urlToTest);
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
if (ipv4Regex.test(urlToTest)) {
|
||||||
|
try {
|
||||||
|
new URL(`http://${urlToTest}`);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domainRegex.test(urlToTest)) {
|
||||||
|
try {
|
||||||
|
new URL(`http://${urlToTest}`);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (simpleDomainRegex.test(urlToTest)) {
|
||||||
|
try {
|
||||||
|
new URL(`http://${urlToTest}`);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generateShareUrl = (
|
export const generateShareUrl = (
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useForm } from '@inertiajs/react';
|
|||||||
import { route } from '@izzyjs/route/client';
|
import { route } from '@izzyjs/route/client';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MantineFormCollection, {
|
import {
|
||||||
|
FormCollection,
|
||||||
FormCollectionData,
|
FormCollectionData,
|
||||||
} from '~/components/form/form_collection';
|
} from '~/components/form/form_collection';
|
||||||
import { Visibility } from '~/types/app';
|
import { Visibility } from '~/types/app';
|
||||||
@@ -29,7 +30,7 @@ export default function CreateCollectionPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineFormCollection
|
<FormCollection
|
||||||
title={t('collection.create')}
|
title={t('collection.create')}
|
||||||
textSubmitButton={t('form.create')}
|
textSubmitButton={t('form.create')}
|
||||||
canSubmit={!isFormDisabled}
|
canSubmit={!isFormDisabled}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useForm } from '@inertiajs/react';
|
import { useForm } from '@inertiajs/react';
|
||||||
import { route } from '@izzyjs/route/client';
|
import { route } from '@izzyjs/route/client';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MantineFormCollection, {
|
import {
|
||||||
|
FormCollection,
|
||||||
FormCollectionData,
|
FormCollectionData,
|
||||||
} from '~/components/form/form_collection';
|
} from '~/components/form/form_collection';
|
||||||
import { Collection } from '~/types/app';
|
import { Collection } from '~/types/app';
|
||||||
@@ -27,7 +28,7 @@ export default function DeleteCollectionPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineFormCollection
|
<FormCollection
|
||||||
title={t('collection.delete')}
|
title={t('collection.delete')}
|
||||||
textSubmitButton={t('form.delete')}
|
textSubmitButton={t('form.delete')}
|
||||||
canSubmit={!processing}
|
canSubmit={!processing}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useForm } from '@inertiajs/react';
|
|||||||
import { route } from '@izzyjs/route/client';
|
import { route } from '@izzyjs/route/client';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MantineFormCollection, {
|
import {
|
||||||
|
FormCollection,
|
||||||
FormCollectionData,
|
FormCollectionData,
|
||||||
} from '~/components/form/form_collection';
|
} from '~/components/form/form_collection';
|
||||||
import { Collection } from '~/types/app';
|
import { Collection } from '~/types/app';
|
||||||
@@ -38,7 +39,7 @@ export default function EditCollectionPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineFormCollection
|
<FormCollection
|
||||||
title={t('collection.edit')}
|
title={t('collection.edit')}
|
||||||
textSubmitButton={t('form.update')}
|
textSubmitButton={t('form.update')}
|
||||||
canSubmit={canSubmit}
|
canSubmit={canSubmit}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useForm } from '@inertiajs/react';
|
|||||||
import { route } from '@izzyjs/route/client';
|
import { route } from '@izzyjs/route/client';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MantineFormLink from '~/components/form/form_link';
|
import { FormLink } from '~/components/form/form_link';
|
||||||
import useSearchParam from '~/hooks/use_search_param';
|
import useSearchParam from '~/hooks/use_search_param';
|
||||||
import { isValidHttpUrl } from '~/lib/navigation';
|
import { isValidHttpUrl } from '~/lib/navigation';
|
||||||
import { Collection } from '~/types/app';
|
import { Collection } from '~/types/app';
|
||||||
@@ -38,7 +38,7 @@ export default function CreateLinkPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineFormLink
|
<FormLink
|
||||||
title={t('link.create')}
|
title={t('link.create')}
|
||||||
textSubmitButton={t('form.create')}
|
textSubmitButton={t('form.create')}
|
||||||
canSubmit={canSubmit}
|
canSubmit={canSubmit}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useForm } from '@inertiajs/react';
|
import { useForm } from '@inertiajs/react';
|
||||||
import { route } from '@izzyjs/route/client';
|
import { route } from '@izzyjs/route/client';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MantineFormLink from '~/components/form/form_link';
|
import { FormLink } from '~/components/form/form_link';
|
||||||
import { LinkWithCollection } from '~/types/app';
|
import { LinkWithCollection } from '~/types/app';
|
||||||
|
|
||||||
export default function DeleteLinkPage({ link }: { link: LinkWithCollection }) {
|
export default function DeleteLinkPage({ link }: { link: LinkWithCollection }) {
|
||||||
@@ -22,7 +22,7 @@ export default function DeleteLinkPage({ link }: { link: LinkWithCollection }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineFormLink
|
<FormLink
|
||||||
title={t('link.delete')}
|
title={t('link.delete')}
|
||||||
textSubmitButton={t('form.delete')}
|
textSubmitButton={t('form.delete')}
|
||||||
canSubmit={!processing}
|
canSubmit={!processing}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useForm } from '@inertiajs/react';
|
|||||||
import { route } from '@izzyjs/route/client';
|
import { route } from '@izzyjs/route/client';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MantineFormLink from '~/components/form/form_link';
|
import { FormLink } from '~/components/form/form_link';
|
||||||
import { isValidHttpUrl } from '~/lib/navigation';
|
import { isValidHttpUrl } from '~/lib/navigation';
|
||||||
import { Collection, Link } from '~/types/app';
|
import { Collection, Link } from '~/types/app';
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export default function EditLinkPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineFormLink
|
<FormLink
|
||||||
title={t('link.edit')}
|
title={t('link.edit')}
|
||||||
textSubmitButton={t('form.update')}
|
textSubmitButton={t('form.update')}
|
||||||
canSubmit={canSubmit}
|
canSubmit={canSubmit}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ApiTokens } from '~/components/common/api_tokens/api_tokens';
|
||||||
import {
|
import {
|
||||||
FloatingTab,
|
FloatingTab,
|
||||||
FloatingTabs,
|
FloatingTabs,
|
||||||
@@ -14,6 +15,11 @@ function UserSettingsShow() {
|
|||||||
value: 'preferences',
|
value: 'preferences',
|
||||||
content: <UserPreferences />,
|
content: <UserPreferences />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t('api-tokens.title'),
|
||||||
|
value: 'api-tokens',
|
||||||
|
content: <ApiTokens />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
return <FloatingTabs tabs={tabs} />;
|
return <FloatingTabs tabs={tabs} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,3 +63,13 @@ export enum Visibility {
|
|||||||
PUBLIC = 'PUBLIC',
|
PUBLIC = 'PUBLIC',
|
||||||
PRIVATE = 'PRIVATE',
|
PRIVATE = 'PRIVATE',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ApiToken = {
|
||||||
|
identifier: number;
|
||||||
|
token: string | undefined;
|
||||||
|
name: string | null;
|
||||||
|
type: 'bearer';
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
abilities: string[];
|
||||||
|
};
|
||||||
|
|||||||
74
package.json
74
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-links",
|
"name": "my-links",
|
||||||
"version": "3.2.0",
|
"version": "3.3.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"#admin/*": "./app/admin/*.js",
|
"#admin/*": "./app/admin/*.js",
|
||||||
"#adonis/api": "./.adonisjs/api.ts",
|
"#adonis/api": "./.adonisjs/api.ts",
|
||||||
"#auth/*": "./app/auth/*.js",
|
"#auth/*": "./app/auth/*.js",
|
||||||
|
"#api/*": "./app/api/*.js",
|
||||||
"#collections/*": "./app/collections/*.js",
|
"#collections/*": "./app/collections/*.js",
|
||||||
"#config/*": "./config/*.js",
|
"#config/*": "./config/*.js",
|
||||||
"#core/*": "./app/core/*.js",
|
"#core/*": "./app/core/*.js",
|
||||||
@@ -43,69 +44,69 @@
|
|||||||
"@adonisjs/eslint-config": "2.1.2",
|
"@adonisjs/eslint-config": "2.1.2",
|
||||||
"@adonisjs/prettier-config": "^1.4.5",
|
"@adonisjs/prettier-config": "^1.4.5",
|
||||||
"@adonisjs/tsconfig": "^1.4.1",
|
"@adonisjs/tsconfig": "^1.4.1",
|
||||||
"@faker-js/faker": "^9.9.0",
|
"@faker-js/faker": "^10.1.0",
|
||||||
"@japa/assert": "^4.1.1",
|
"@japa/assert": "^4.1.1",
|
||||||
"@japa/plugin-adonisjs": "^4.0.0",
|
"@japa/plugin-adonisjs": "^4.0.0",
|
||||||
"@japa/runner": "^4.4.0",
|
"@japa/runner": "^4.4.0",
|
||||||
"@swc/core": "^1.13.3",
|
"@swc/core": "^1.15.1",
|
||||||
"@tuyau/utils": "^0.0.9",
|
"@tuyau/utils": "^0.0.9",
|
||||||
"@types/luxon": "^3.7.1",
|
"@types/luxon": "^3.7.1",
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.2.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.40.0",
|
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||||
"@vite-pwa/assets-generator": "^1.0.0",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"eslint": "^9.33.0",
|
"eslint": "^9.39.1",
|
||||||
"hot-hook": "^0.4.0",
|
"hot-hook": "^0.4.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.1.5",
|
"lint-staged": "^16.2.6",
|
||||||
"pino-pretty": "^13.1.1",
|
"pino-pretty": "^13.1.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"release-it": "^19.0.4",
|
"release-it": "^19.0.6",
|
||||||
"ts-node-maintained": "^10.9.6",
|
"ts-node-maintained": "^10.9.6",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.1.3"
|
"vite": "^7.2.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@adonisjs/ally": "^5.1.0",
|
"@adonisjs/ally": "^5.1.1",
|
||||||
"@adonisjs/auth": "^9.4.2",
|
"@adonisjs/auth": "^9.5.1",
|
||||||
"@adonisjs/core": "^6.19.0",
|
"@adonisjs/core": "^6.19.1",
|
||||||
"@adonisjs/cors": "^2.2.1",
|
"@adonisjs/cors": "^2.2.1",
|
||||||
"@adonisjs/inertia": "^3.1.1",
|
"@adonisjs/inertia": "^3.1.1",
|
||||||
"@adonisjs/lucid": "^21.8.0",
|
"@adonisjs/lucid": "^21.8.1",
|
||||||
"@adonisjs/session": "^7.5.1",
|
"@adonisjs/session": "^7.5.1",
|
||||||
"@adonisjs/shield": "^8.2.0",
|
"@adonisjs/shield": "^8.2.0",
|
||||||
"@adonisjs/static": "^1.1.1",
|
"@adonisjs/static": "^1.1.1",
|
||||||
"@adonisjs/vite": "^4.0.0",
|
"@adonisjs/vite": "^4.0.0",
|
||||||
"@inertiajs/react": "^2.1.2",
|
"@inertiajs/react": "^2.2.15",
|
||||||
"@izzyjs/route": "^1.2.0",
|
"@izzyjs/route": "^2.1.7",
|
||||||
"@mantine/core": "^8.2.5",
|
"@mantine/core": "^8.3.6",
|
||||||
"@mantine/hooks": "^8.2.5",
|
"@mantine/hooks": "^8.3.6",
|
||||||
"@mantine/modals": "^8.2.5",
|
"@mantine/modals": "^8.3.6",
|
||||||
"@mantine/spotlight": "^8.2.5",
|
"@mantine/spotlight": "^8.3.6",
|
||||||
"@tuyau/client": "^0.2.10",
|
"@tuyau/client": "^0.2.10",
|
||||||
"@tuyau/core": "^0.4.2",
|
"@tuyau/core": "^0.4.2",
|
||||||
"@tuyau/inertia": "^0.0.15",
|
"@tuyau/inertia": "^0.0.15",
|
||||||
"@vinejs/vine": "^3.0.1",
|
"@vinejs/vine": "^4.1.0",
|
||||||
"@vitejs/plugin-react-oxc": "^0.3.0",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
"bentocache": "^1.5.0",
|
"bentocache": "^1.5.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.19",
|
||||||
"edge.js": "^6.3.0",
|
"edge.js": "^6.3.0",
|
||||||
"i18next": "^25.4.0",
|
"i18next": "^25.6.1",
|
||||||
"knex": "^3.1.0",
|
"knex": "^3.1.0",
|
||||||
"luxon": "^3.7.1",
|
"luxon": "^3.7.2",
|
||||||
"node-html-parser": "^7.0.1",
|
"node-html-parser": "^7.0.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "^19.1.1",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.2.0",
|
||||||
"react-i18next": "^15.7.0",
|
"react-i18next": "^16.2.4",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"vite-plugin-pwa": "^1.0.3",
|
"vite-plugin-pwa": "^1.1.0",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"hotHook": {
|
"hotHook": {
|
||||||
@@ -128,11 +129,6 @@
|
|||||||
"*.js,*.ts,*.jsx,*.tsx": "eslint --cache --fix"
|
"*.js,*.ts,*.jsx,*.tsx": "eslint --cache --fix"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.6.0"
|
"node": "24.11.0"
|
||||||
},
|
|
||||||
"pnpm": {
|
|
||||||
"overrides": {
|
|
||||||
"vite": "npm:rolldown-vite@latest"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2203
pnpm-lock.yaml
generated
2203
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
|||||||
import '#admin/routes/routes';
|
import '#admin/routes/routes';
|
||||||
|
import '#api/collections/routes/routes';
|
||||||
|
import '#api/links/routes/routes';
|
||||||
|
import '#api/tokens/routes/routes';
|
||||||
import '#auth/routes/routes';
|
import '#auth/routes/routes';
|
||||||
import '#collections/routes/routes';
|
import '#collections/routes/routes';
|
||||||
import '#favicons/routes/routes';
|
import '#favicons/routes/routes';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { assert } from '@japa/assert';
|
|
||||||
import app from '@adonisjs/core/services/app';
|
import app from '@adonisjs/core/services/app';
|
||||||
import type { Config } from '@japa/runner/types';
|
|
||||||
import { pluginAdonisJS } from '@japa/plugin-adonisjs';
|
|
||||||
import testUtils from '@adonisjs/core/services/test_utils';
|
import testUtils from '@adonisjs/core/services/test_utils';
|
||||||
import { izzyRoutePlugin } from '@izzyjs/route/plugins/japa';
|
import { izzyRoutePlugin } from '@izzyjs/route/plugins/japa';
|
||||||
|
import { assert } from '@japa/assert';
|
||||||
|
import { pluginAdonisJS } from '@japa/plugin-adonisjs';
|
||||||
|
import type { Config } from '@japa/runner/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This file is imported by the "bin/test.ts" entrypoint file
|
* This file is imported by the "bin/test.ts" entrypoint file
|
||||||
@@ -16,7 +16,7 @@ import { izzyRoutePlugin } from '@izzyjs/route/plugins/japa';
|
|||||||
export const plugins: Config['plugins'] = [
|
export const plugins: Config['plugins'] = [
|
||||||
assert(),
|
assert(),
|
||||||
pluginAdonisJS(app),
|
pluginAdonisJS(app),
|
||||||
izzyRoutePlugin(),
|
izzyRoutePlugin(app),
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import { getDirname } from '@adonisjs/core/helpers';
|
import { getDirname } from '@adonisjs/core/helpers';
|
||||||
import inertia from '@adonisjs/inertia/client';
|
import inertia from '@adonisjs/inertia/client';
|
||||||
import adonisjs from '@adonisjs/vite/client';
|
import adonisjs from '@adonisjs/vite/client';
|
||||||
import react from '@vitejs/plugin-react-oxc';
|
import react from '@vitejs/plugin-react';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user