mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-08 22:53:25 +00:00
Compare commits
5 Commits
3.2.0
...
208f2c631f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
208f2c631f | ||
|
|
9aa71dad30 | ||
|
|
d00b6b9edd | ||
|
|
376e9e32c3 | ||
|
|
c2a1d06008 |
@@ -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
|
||||
*/
|
||||
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) {
|
||||
return ctx.response.redirectToNamedRoute('dashboard');
|
||||
}
|
||||
|
||||
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 { ensureDisplayPreferences } from '#user/lib/index';
|
||||
import type { GoogleToken } from '@adonisjs/ally/types';
|
||||
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens';
|
||||
import { column, computed, hasMany } from '@adonisjs/lucid/orm';
|
||||
import type { HasMany } from '@adonisjs/lucid/types/relations';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -64,4 +65,6 @@ export default class User extends AppBaseModel {
|
||||
prepare: (value) => JSON.stringify(value),
|
||||
})
|
||||
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_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),
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
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.getTokens(user);
|
||||
return inertia.render('user_settings/show', {
|
||||
user,
|
||||
tokens: tokens.map((token) => {
|
||||
return {
|
||||
...token.toJSON(),
|
||||
identifier: token.identifier,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
1
app/user_settings/routes/routes.ts
Normal file
1
app/user_settings/routes/routes.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './user_settings_routes.js';
|
||||
8
app/user_settings/routes/user_settings_routes.ts
Normal file
8
app/user_settings/routes/user_settings_routes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import router from '@adonisjs/core/services/router';
|
||||
|
||||
const ShowUserSettingsController = () =>
|
||||
import('#user_settings/controllers/show_user_settings_controller');
|
||||
|
||||
router
|
||||
.get('/user/settings', [ShowUserSettingsController, 'render'])
|
||||
.as('user.settings');
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from '@adonisjs/auth';
|
||||
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens';
|
||||
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session';
|
||||
import { Authenticators, InferAuthEvents } from '@adonisjs/auth/types';
|
||||
|
||||
@@ -11,6 +12,12 @@ const authConfig = defineConfig({
|
||||
model: () => import('#user/models/user'),
|
||||
}),
|
||||
}),
|
||||
api: tokensGuard({
|
||||
provider: tokensUserProvider({
|
||||
tokens: 'accessTokens',
|
||||
model: () => import('#user/models/user'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
||||
*/
|
||||
sharedData: {
|
||||
errors: (ctx) => ctx.session?.flashMessages.get('errors'),
|
||||
token: (ctx) => ctx.session?.flashMessages.get('token'),
|
||||
user: (ctx) => ({
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Avatar, Group, Menu, Text, UnstyledButton } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { modals } from '@mantine/modals';
|
||||
import cx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TbChevronDown, TbLogout, TbSettings, TbShield } from 'react-icons/tb';
|
||||
import { InternalLinkUnstyled } from '~/components/common/links/internal_link_unstyled';
|
||||
import { UserPreferences } from '~/components/common/user_preferences/user_preferences';
|
||||
import { useAuth } from '~/hooks/use_auth';
|
||||
import classes from './user_dropdown.module.css';
|
||||
|
||||
@@ -15,13 +13,6 @@ export function UserDropdown() {
|
||||
useDisclosure(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handlePreferencesModal = () => {
|
||||
modals.open({
|
||||
title: t('user-preferences'),
|
||||
children: <UserPreferences />,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
width={260}
|
||||
@@ -41,6 +32,9 @@ export function UserDropdown() {
|
||||
alt={auth.user?.fullname}
|
||||
radius="xl"
|
||||
size={20}
|
||||
imageProps={{
|
||||
referrerPolicy: 'no-referrer',
|
||||
}}
|
||||
/>
|
||||
<Text fw={500} size="sm" lh={1} mr={3}>
|
||||
{auth.user?.fullname}
|
||||
@@ -64,12 +58,13 @@ export function UserDropdown() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu.Label>{t('common:settings')}</Menu.Label>
|
||||
<Menu.Label>{t('common:user')}</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<TbSettings size={16} />}
|
||||
onClick={handlePreferencesModal}
|
||||
component={InternalLinkUnstyled}
|
||||
href="/user/settings"
|
||||
>
|
||||
{t('common:preferences')}
|
||||
{t('common:settings')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<TbLogout size={16} />}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
.list {
|
||||
position: relative;
|
||||
margin-bottom: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.indicator {
|
||||
z-index: -1 !important;
|
||||
background-color: var(--mantine-color-white);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: 1px solid var(--mantine-color-gray-2);
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-6);
|
||||
border-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
}
|
||||
|
||||
.tab {
|
||||
z-index: 1;
|
||||
font-weight: 500;
|
||||
transition: color 100ms ease;
|
||||
color: var(--mantine-color-gray-7);
|
||||
|
||||
&[data-active] {
|
||||
color: var(--mantine-color-black);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
color: var(--mantine-color-dark-1);
|
||||
|
||||
&[data-active] {
|
||||
color: var(--mantine-color-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
79
inertia/components/common/floating_tabs/floating_tabs.tsx
Normal file
79
inertia/components/common/floating_tabs/floating_tabs.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
FloatingIndicator,
|
||||
Indicator,
|
||||
Tabs as MantineTabs,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import classes from './floating_tabs.module.css';
|
||||
|
||||
export type FloatingTab = {
|
||||
value: string;
|
||||
label: string;
|
||||
content: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
indicator?: {
|
||||
content: string;
|
||||
color?: string;
|
||||
pulse?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
interface FloatingTabsProps {
|
||||
tabs: FloatingTab[];
|
||||
keepMounted?: boolean;
|
||||
}
|
||||
|
||||
export function FloatingTabs({ tabs, keepMounted = false }: FloatingTabsProps) {
|
||||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
||||
const [value, setValue] = useState<string | null>(tabs[0].value);
|
||||
const [controlsRefs, setControlsRefs] = useState<
|
||||
Record<string, HTMLButtonElement | null>
|
||||
>({});
|
||||
const setControlRef = (val: string) => (node: HTMLButtonElement) => {
|
||||
controlsRefs[val] = node;
|
||||
setControlsRefs(controlsRefs);
|
||||
};
|
||||
|
||||
return (
|
||||
<MantineTabs
|
||||
variant="none"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
keepMounted={keepMounted}
|
||||
>
|
||||
<MantineTabs.List ref={setRootRef} className={classes.list}>
|
||||
{tabs.map((tab) => (
|
||||
<Indicator
|
||||
label={tab.indicator?.content}
|
||||
color={tab.indicator?.color}
|
||||
processing={tab.indicator?.pulse}
|
||||
disabled={!tab.indicator || tab.indicator.disabled}
|
||||
size={16}
|
||||
key={tab.value}
|
||||
>
|
||||
<MantineTabs.Tab
|
||||
value={tab.value}
|
||||
ref={setControlRef(tab.value)}
|
||||
className={classes.tab}
|
||||
disabled={tab.disabled}
|
||||
>
|
||||
{tab.label}
|
||||
</MantineTabs.Tab>
|
||||
</Indicator>
|
||||
))}
|
||||
<FloatingIndicator
|
||||
target={value ? controlsRefs[value] : null}
|
||||
parent={rootRef}
|
||||
className={classes.indicator}
|
||||
/>
|
||||
</MantineTabs.List>
|
||||
{tabs.map((tab) => (
|
||||
<MantineTabs.Panel key={tab.value} value={tab.value}>
|
||||
<Stack>{tab.content}</Stack>
|
||||
</MantineTabs.Panel>
|
||||
))}
|
||||
</MantineTabs>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,38 @@
|
||||
import { AUTHOR_GITHUB_URL, AUTHOR_NAME } from '#config/project';
|
||||
import PATHS from '#core/constants/paths';
|
||||
import { route } from '@izzyjs/route/client';
|
||||
import { Anchor, Group, Text } from '@mantine/core';
|
||||
import { withTranslation, WithTranslation } from 'react-i18next';
|
||||
import ExternalLink from '~/components/common/external_link';
|
||||
import { ExternalLinkStyled } from '~/components/common/links/external_link_styled';
|
||||
import { InternalLink } from '~/components/common/links/internal_link';
|
||||
import { LocaleSwitcher } from '~/components/common/locale_switcher';
|
||||
import { ThemeSwitcher } from '~/components/common/theme_switcher';
|
||||
import packageJson from '../../../../package.json';
|
||||
import classes from './footer.module.css';
|
||||
|
||||
export const Footer = () => (
|
||||
export const Footer = withTranslation()(({ t }: WithTranslation) => (
|
||||
<Group className={classes.footer}>
|
||||
<Group className={classes.footer__content}>
|
||||
<Text>
|
||||
Made with ❤️ by{' '}
|
||||
{t('footer.made_by')}{' '}
|
||||
<ExternalLinkStyled href={AUTHOR_GITHUB_URL}>
|
||||
{AUTHOR_NAME}
|
||||
</ExternalLinkStyled>
|
||||
</Text>
|
||||
•
|
||||
<Text>
|
||||
<Anchor size="sm" component={ExternalLink} href={PATHS.REPO_GITHUB}>
|
||||
{packageJson.version}
|
||||
</Anchor>
|
||||
</Text>
|
||||
•
|
||||
<Group gap="sm" mt={4} mb={4}>
|
||||
<Group gap="sm">
|
||||
<ThemeSwitcher />
|
||||
<LocaleSwitcher />
|
||||
</Group>
|
||||
•
|
||||
<Group gap="sm">
|
||||
<Anchor size="sm" component={ExternalLink} href={PATHS.REPO_GITHUB}>
|
||||
{packageJson.version}
|
||||
</Anchor>
|
||||
<InternalLink href={route('privacy').path}>{t('privacy')}</InternalLink>
|
||||
<InternalLink href={route('terms').path}>{t('terms')}</InternalLink>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
import { LinkListDisplay } from '#shared/types/index';
|
||||
import { Fieldset, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ComboList } from '~/components/common/combo_list/combo_list';
|
||||
import { CollectionListSelector } from '~/components/dashboard/collection/collection_list_selector';
|
||||
import { useDisplayPreferences } from '~/hooks/use_display_preferences';
|
||||
import { LinkListSelector } from '~/components/dashboard/link/link_list_selector';
|
||||
import { useIsMobile } from '~/hooks/use_is_mobile';
|
||||
import { getLinkListDisplayOptions } from '~/lib/display_preferences';
|
||||
|
||||
export function UserPreferences() {
|
||||
const { displayPreferences, handleUpdateDisplayPreferences } =
|
||||
useDisplayPreferences();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -28,15 +23,7 @@ export function UserPreferences() {
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('display-preferences.link-list-display')}
|
||||
</Text>
|
||||
<ComboList
|
||||
selectedValue={displayPreferences.linkListDisplay}
|
||||
values={getLinkListDisplayOptions()}
|
||||
setValue={(value) =>
|
||||
handleUpdateDisplayPreferences({
|
||||
linkListDisplay: value as LinkListDisplay,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<LinkListSelector />
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { COLLECTION_LIST_DISPLAYS } from '#shared/lib/display_preferences';
|
||||
import { CollectionListDisplay } from '#shared/types/index';
|
||||
import { ComboList } from '~/components/common/combo_list/combo_list';
|
||||
import { SegmentedControl } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDisplayPreferences } from '~/hooks/use_display_preferences';
|
||||
import { getCollectionListDisplayOptions } from '~/lib/display_preferences';
|
||||
|
||||
export function CollectionListSelector() {
|
||||
const { t } = useTranslation();
|
||||
const { displayPreferences, handleUpdateDisplayPreferences } =
|
||||
useDisplayPreferences();
|
||||
|
||||
const data = COLLECTION_LIST_DISPLAYS.map((display) => ({
|
||||
label: t(`display-preferences.${display}`),
|
||||
value: display,
|
||||
}));
|
||||
return (
|
||||
<ComboList
|
||||
selectedValue={displayPreferences.collectionListDisplay}
|
||||
values={getCollectionListDisplayOptions()}
|
||||
setValue={(value) =>
|
||||
<SegmentedControl
|
||||
data={data}
|
||||
value={displayPreferences.collectionListDisplay}
|
||||
onChange={(value) =>
|
||||
handleUpdateDisplayPreferences({
|
||||
collectionListDisplay: value as CollectionListDisplay,
|
||||
})
|
||||
}
|
||||
w="50%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
28
inertia/components/dashboard/link/link_list_selector.tsx
Normal file
28
inertia/components/dashboard/link/link_list_selector.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { LINK_LIST_DISPLAYS } from '#shared/lib/display_preferences';
|
||||
import { LinkListDisplay } from '#shared/types/index';
|
||||
import { SegmentedControl } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDisplayPreferences } from '~/hooks/use_display_preferences';
|
||||
|
||||
export function LinkListSelector() {
|
||||
const { t } = useTranslation();
|
||||
const { displayPreferences, handleUpdateDisplayPreferences } =
|
||||
useDisplayPreferences();
|
||||
|
||||
const data = LINK_LIST_DISPLAYS.map((display) => ({
|
||||
label: t(`display-preferences.${display}`),
|
||||
value: display,
|
||||
}));
|
||||
return (
|
||||
<SegmentedControl
|
||||
data={data}
|
||||
value={displayPreferences.linkListDisplay}
|
||||
onChange={(value) =>
|
||||
handleUpdateDisplayPreferences({
|
||||
linkListDisplay: value as LinkListDisplay,
|
||||
})
|
||||
}
|
||||
w="50%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
.footer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: $mantine-breakpoint-xs) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.links {
|
||||
@media (max-width: $mantine-breakpoint-xs) {
|
||||
margin-top: var(--mantine-spacing-md);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import PATHS from '#core/constants/paths';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { route } from '@izzyjs/route/client';
|
||||
import { Anchor, Group, Text } from '@mantine/core';
|
||||
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 packageJson from '../../../package.json';
|
||||
import classes from './footer.module.css';
|
||||
|
||||
export function MantineFooter() {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const links = [
|
||||
{ link: route('privacy').path, label: t('privacy'), external: false },
|
||||
{ link: route('terms').path, label: t('terms'), external: false },
|
||||
{ link: PATHS.EXTENSION, label: 'Extension', external: true },
|
||||
];
|
||||
|
||||
const items = links.map((link) => (
|
||||
<Anchor
|
||||
c="dimmed"
|
||||
// @ts-expect-error
|
||||
component={link.external ? ExternalLink : Link}
|
||||
key={link.label}
|
||||
href={link.link}
|
||||
size="sm"
|
||||
>
|
||||
{link.label}
|
||||
</Anchor>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className={classes.footer}>
|
||||
<div className={classes.inner}>
|
||||
<Group gap={4} c="dimmed">
|
||||
<Text size="sm">{t('footer.made_by')}</Text>{' '}
|
||||
<Anchor size="sm" component={ExternalLink} href={PATHS.AUTHOR}>
|
||||
Sonny
|
||||
</Anchor>
|
||||
{' • '}
|
||||
<Anchor size="sm" component={ExternalLink} href={PATHS.REPO_GITHUB}>
|
||||
{packageJson.version}
|
||||
</Anchor>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" mt={4} mb={4}>
|
||||
<ThemeSwitcher />
|
||||
<LocaleSwitcher />
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{items}
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -89,7 +89,32 @@
|
||||
"preferences-description": "Display preferences do not apply on mobile",
|
||||
"display-preferences": {
|
||||
"collection-list-display": "Collection list display",
|
||||
"link-list-display": "Link list display"
|
||||
"link-list-display": "Link list display",
|
||||
"inline": "Inline",
|
||||
"list": "List",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,32 @@
|
||||
"preferences-description": "Les préférences d'affichage ne s'appliquent pas sur mobile",
|
||||
"display-preferences": {
|
||||
"collection-list-display": "Affichage de la liste des collections",
|
||||
"link-list-display": "Affichage de la liste des liens"
|
||||
"link-list-display": "Affichage de la liste des liens",
|
||||
"inline": "En ligne",
|
||||
"list": "Liste",
|
||||
"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éé"
|
||||
}
|
||||
}
|
||||
|
||||
45
inertia/layouts/small_content.tsx
Normal file
45
inertia/layouts/small_content.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Box, rem } from '@mantine/core';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { FloatingNavbar } from '~/components/common/floating_navbar/floating_navbar';
|
||||
import { Footer } from '~/components/common/footer/footer';
|
||||
import { BaseLayout } from './_base_layout';
|
||||
|
||||
const SmallContentLayout = ({ children }: PropsWithChildren) => (
|
||||
<BaseLayout>
|
||||
<Layout>{children}</Layout>
|
||||
</BaseLayout>
|
||||
);
|
||||
|
||||
export default SmallContentLayout;
|
||||
|
||||
const LAYOUT_WIDTH = '1500px';
|
||||
const CONTENT_WIDTH = '800px';
|
||||
const Layout = ({ children }: PropsWithChildren) => (
|
||||
<>
|
||||
{/* Top navbar */}
|
||||
<FloatingNavbar width={LAYOUT_WIDTH} />
|
||||
|
||||
{/* Page content */}
|
||||
<Box
|
||||
style={{
|
||||
paddingInline: 'var(--mantine-spacing-lg)',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
width: CONTENT_WIDTH,
|
||||
marginInline: 'auto',
|
||||
marginBlock: rem(30),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
COLLECTION_LIST_DISPLAYS,
|
||||
LINK_LIST_DISPLAYS,
|
||||
} from '#shared/lib/display_preferences';
|
||||
import { AiOutlineFolder } from 'react-icons/ai';
|
||||
import { IoGridOutline } from 'react-icons/io5';
|
||||
import { TbList } from 'react-icons/tb';
|
||||
import { ValueWithIcon } from '~/components/common/combo_list/combo_list';
|
||||
|
||||
const collectionListDisplayIcons = {
|
||||
list: <TbList size={20} />,
|
||||
inline: <AiOutlineFolder size={20} />,
|
||||
} as const;
|
||||
|
||||
export function getCollectionListDisplayOptions(): ValueWithIcon[] {
|
||||
return COLLECTION_LIST_DISPLAYS.map((display) => ({
|
||||
label: display,
|
||||
value: display,
|
||||
icon: collectionListDisplayIcons[display],
|
||||
}));
|
||||
}
|
||||
|
||||
const linkListDisplayIcons = {
|
||||
list: <TbList size={20} />,
|
||||
grid: <IoGridOutline size={20} />,
|
||||
} as const;
|
||||
|
||||
export function getLinkListDisplayOptions(): ValueWithIcon[] {
|
||||
return LINK_LIST_DISPLAYS.map((display) => ({
|
||||
label: display,
|
||||
value: display,
|
||||
icon: linkListDisplayIcons[display],
|
||||
}));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SmallContentLayout from '~/layouts/small_content';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
function PrivacyPage() {
|
||||
const { t } = useTranslation('privacy');
|
||||
return (
|
||||
<>
|
||||
@@ -41,3 +42,8 @@ export default function PrivacyPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
PrivacyPage.layout = (page: React.ReactNode) => (
|
||||
<SmallContentLayout>{page}</SmallContentLayout>
|
||||
);
|
||||
export default PrivacyPage;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { route } from '@izzyjs/route/client';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import SmallContentLayout from '~/layouts/small_content';
|
||||
|
||||
export default function TermsPage() {
|
||||
function TermsPage() {
|
||||
const { t } = useTranslation('terms');
|
||||
return (
|
||||
<>
|
||||
@@ -28,7 +29,7 @@ export default function TermsPage() {
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="personal_data.collect.description"
|
||||
components={{ a: <Link href={route('privacy').url} /> }}
|
||||
components={{ a: <Link href={route('privacy').path} /> }}
|
||||
/>
|
||||
</p>
|
||||
|
||||
@@ -50,3 +51,8 @@ export default function TermsPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TermsPage.layout = (page: React.ReactNode) => (
|
||||
<SmallContentLayout>{page}</SmallContentLayout>
|
||||
);
|
||||
export default TermsPage;
|
||||
|
||||
30
inertia/pages/user_settings/show.tsx
Normal file
30
inertia/pages/user_settings/show.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ApiTokens } from '~/components/common/api_tokens/api_tokens';
|
||||
import {
|
||||
FloatingTab,
|
||||
FloatingTabs,
|
||||
} from '~/components/common/floating_tabs/floating_tabs';
|
||||
import { UserPreferences } from '~/components/common/user_preferences/user_preferences';
|
||||
import SmallContentLayout from '~/layouts/small_content';
|
||||
|
||||
function UserSettingsShow() {
|
||||
const { t } = useTranslation();
|
||||
const tabs: FloatingTab[] = [
|
||||
{
|
||||
label: t('preferences'),
|
||||
value: 'preferences',
|
||||
content: <UserPreferences />,
|
||||
},
|
||||
{
|
||||
label: t('api-tokens.title'),
|
||||
value: 'api-tokens',
|
||||
content: <ApiTokens />,
|
||||
},
|
||||
];
|
||||
return <FloatingTabs tabs={tabs} />;
|
||||
}
|
||||
|
||||
UserSettingsShow.layout = (page: React.ReactNode) => (
|
||||
<SmallContentLayout>{page}</SmallContentLayout>
|
||||
);
|
||||
export default UserSettingsShow;
|
||||
@@ -63,3 +63,13 @@ export enum Visibility {
|
||||
PUBLIC = 'PUBLIC',
|
||||
PRIVATE = 'PRIVATE',
|
||||
}
|
||||
|
||||
export type ApiToken = {
|
||||
identifier: number;
|
||||
token: string | undefined;
|
||||
name: string | null;
|
||||
type: 'bearer';
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
abilities: string[];
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"#admin/*": "./app/admin/*.js",
|
||||
"#adonis/api": "./.adonisjs/api.ts",
|
||||
"#auth/*": "./app/auth/*.js",
|
||||
"#api/*": "./app/api/*.js",
|
||||
"#collections/*": "./app/collections/*.js",
|
||||
"#config/*": "./config/*.js",
|
||||
"#core/*": "./app/core/*.js",
|
||||
@@ -31,6 +32,7 @@
|
||||
"#search/*": "./app/search/*.js",
|
||||
"#shared_collections/*": "./app/shared_collections/*.js",
|
||||
"#user/*": "./app/user/*.js",
|
||||
"#user_settings/*": "./app/user_settings/*.js",
|
||||
"#providers/*": "./providers/*.js",
|
||||
"#database/*": "./database/*.js",
|
||||
"#tests/*": "./tests/*.js",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import '#admin/routes/routes';
|
||||
import '#api/collections/routes/routes';
|
||||
import '#api/links/routes/routes';
|
||||
import '#api/tokens/routes/routes';
|
||||
import '#auth/routes/routes';
|
||||
import '#collections/routes/routes';
|
||||
import '#favicons/routes/routes';
|
||||
@@ -8,3 +11,4 @@ import '#links/routes/routes';
|
||||
import '#search/routes/routes';
|
||||
import '#shared_collections/routes/routes';
|
||||
import '#user/routes/routes';
|
||||
import '#user_settings/routes/routes';
|
||||
|
||||
Reference in New Issue
Block a user