3 Commits

49 changed files with 852 additions and 150 deletions

View File

@@ -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(),
});
}
}

View File

@@ -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',
});
}
}

View File

@@ -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()),
});
}
}

View File

@@ -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',
});
}
}

View 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'] })]);

View 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'] })]);

View 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'] })]);

View 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'] })]);

View 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';

View 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(),
});
}
}

View 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);
}
}

View 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);
}
}

View 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',
});
}
}

View 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'] })]);

View 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'] })]);

View 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'] })]);

View 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'] })]);

View 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';

View 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',
});
}
}

View 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';
}

View 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);
}

View 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'] })]);

View File

@@ -0,0 +1 @@
import '#api/tokens/routes/api_token_routes';

View File

@@ -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');
}

View 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();
}
}

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -0,0 +1,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);
}
}

View File

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

View File

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

View File

@@ -1,10 +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,
};
}),
});
}
}

View File

@@ -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'),
}),
}),
},
});

View File

@@ -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),
}),

View File

@@ -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);
}
}

View 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>
);
}

View 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>
);
}

View File

@@ -32,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}

View File

@@ -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);
}

View 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>
);
}

View File

@@ -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)
);
}
}

View File

@@ -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>
);
}

View 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,
};
}

View File

@@ -94,5 +94,27 @@
"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"
}
}

View File

@@ -94,5 +94,27 @@
"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éé"
}
}

View File

@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
import { ApiTokens } from '~/components/common/api_tokens/api_tokens';
import {
FloatingTab,
FloatingTabs,
@@ -14,6 +15,11 @@ function UserSettingsShow() {
value: 'preferences',
content: <UserPreferences />,
},
{
label: t('api-tokens.title'),
value: 'api-tokens',
content: <ApiTokens />,
},
];
return <FloatingTabs tabs={tabs} />;
}

View File

@@ -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[];
};

View File

@@ -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",

View File

@@ -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';