mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-08 14:43:24 +00:00
refactor: use adonis's access tokens instead of creating custom (and unsecured) logic
This commit is contained in:
@@ -3,21 +3,23 @@ import { createApiTokenValidator } from '#user/validators/token/create_api_token
|
|||||||
import { deleteApiTokenValidator } from '#user/validators/token/delete_api_token';
|
import { deleteApiTokenValidator } from '#user/validators/token/delete_api_token';
|
||||||
import { inject } from '@adonisjs/core';
|
import { inject } from '@adonisjs/core';
|
||||||
import { HttpContext } from '@adonisjs/core/http';
|
import { HttpContext } from '@adonisjs/core/http';
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
@inject()
|
@inject()
|
||||||
export default class ApiTokenController {
|
export default class ApiTokenController {
|
||||||
constructor(private apiTokenService: ApiTokenService) {}
|
constructor(private apiTokenService: ApiTokenService) {}
|
||||||
|
|
||||||
async store({ request, response, auth }: HttpContext) {
|
async store({ request, response, auth, session }: HttpContext) {
|
||||||
const { name, expiresAt } = await request.validateUsing(
|
const { name, expiresAt } = await request.validateUsing(
|
||||||
createApiTokenValidator
|
createApiTokenValidator
|
||||||
);
|
);
|
||||||
|
const token = await this.apiTokenService.createToken(auth.user!, {
|
||||||
await this.apiTokenService.createToken({
|
|
||||||
user: auth.user!,
|
|
||||||
name,
|
name,
|
||||||
expiresAt: expiresAt ? DateTime.fromJSDate(expiresAt) : undefined,
|
expiresAt,
|
||||||
|
});
|
||||||
|
session.flash('token', {
|
||||||
|
...token.toJSON(),
|
||||||
|
token: token.value?.release(),
|
||||||
|
identifier: token.identifier,
|
||||||
});
|
});
|
||||||
return response.redirect().withQs().back();
|
return response.redirect().withQs().back();
|
||||||
}
|
}
|
||||||
@@ -26,7 +28,18 @@ export default class ApiTokenController {
|
|||||||
const { params } = await request.validateUsing(deleteApiTokenValidator);
|
const { params } = await request.validateUsing(deleteApiTokenValidator);
|
||||||
const tokenId = params.tokenId;
|
const tokenId = params.tokenId;
|
||||||
|
|
||||||
await this.apiTokenService.revokeToken(tokenId, auth.user!.id);
|
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();
|
return response.redirect().withQs().back();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import AppBaseModel from '#core/models/app_base_model';
|
|
||||||
import User from '#user/models/user';
|
|
||||||
import { beforeSave, belongsTo, column } from '@adonisjs/lucid/orm';
|
|
||||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
|
|
||||||
export default class ApiToken extends AppBaseModel {
|
|
||||||
@column()
|
|
||||||
declare userId: number;
|
|
||||||
|
|
||||||
@column()
|
|
||||||
declare name: string;
|
|
||||||
|
|
||||||
@column()
|
|
||||||
declare token: string;
|
|
||||||
|
|
||||||
@column.dateTime()
|
|
||||||
declare lastUsedAt: DateTime | null;
|
|
||||||
|
|
||||||
@column.dateTime()
|
|
||||||
declare expiresAt: DateTime | null;
|
|
||||||
|
|
||||||
@column()
|
|
||||||
declare isActive: boolean;
|
|
||||||
|
|
||||||
@belongsTo(() => User, {
|
|
||||||
foreignKey: 'userId',
|
|
||||||
})
|
|
||||||
declare user: BelongsTo<typeof User>;
|
|
||||||
|
|
||||||
isExpired(): boolean {
|
|
||||||
if (!this.expiresAt) return false;
|
|
||||||
return DateTime.now() > this.expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
isValid(): boolean {
|
|
||||||
return this.isActive && !this.isExpired();
|
|
||||||
}
|
|
||||||
|
|
||||||
async markAsUsed(): Promise<void> {
|
|
||||||
this.lastUsedAt = DateTime.now();
|
|
||||||
await this.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
@beforeSave()
|
|
||||||
static async generateToken(token: ApiToken) {
|
|
||||||
if (!token.token) {
|
|
||||||
token.token = randomBytes(32).toString('hex');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,8 @@ import AppBaseModel from '#core/models/app_base_model';
|
|||||||
import Link from '#links/models/link';
|
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 ApiToken from '#user/models/api_token';
|
|
||||||
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';
|
||||||
@@ -44,11 +44,6 @@ export default class User extends AppBaseModel {
|
|||||||
})
|
})
|
||||||
declare links: HasMany<typeof Link>;
|
declare links: HasMany<typeof Link>;
|
||||||
|
|
||||||
@hasMany(() => ApiToken, {
|
|
||||||
foreignKey: 'userId',
|
|
||||||
})
|
|
||||||
declare apiTokens: HasMany<typeof ApiToken>;
|
|
||||||
|
|
||||||
@computed()
|
@computed()
|
||||||
get fullname() {
|
get fullname() {
|
||||||
return this.nickName || this.name;
|
return this.nickName || this.name;
|
||||||
@@ -70,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,33 @@
|
|||||||
import ApiToken from '#user/models/api_token';
|
|
||||||
import User from '#user/models/user';
|
import User from '#user/models/user';
|
||||||
import { DateTime } from 'luxon';
|
import { AccessToken } from '@adonisjs/auth/access_tokens';
|
||||||
|
|
||||||
type CreateApiTokenPayload = {
|
type CreateTokenParams = {
|
||||||
user: User;
|
|
||||||
name: string;
|
name: string;
|
||||||
expiresAt?: DateTime;
|
expiresAt?: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ApiTokenService {
|
export class ApiTokenService {
|
||||||
async createToken({
|
createToken(user: User, { name, expiresAt }: CreateTokenParams) {
|
||||||
user,
|
const expiresIn = expiresAt ? expiresAt.getTime() - Date.now() : undefined;
|
||||||
name,
|
return User.accessTokens.create(user, undefined, {
|
||||||
expiresAt,
|
|
||||||
}: CreateApiTokenPayload): Promise<ApiToken> {
|
|
||||||
return await ApiToken.create({
|
|
||||||
userId: user.id,
|
|
||||||
name,
|
name,
|
||||||
expiresAt,
|
expiresIn,
|
||||||
isActive: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserTokens(userId: number): Promise<ApiToken[]> {
|
getTokens(user: User) {
|
||||||
return await ApiToken.query()
|
return User.accessTokens.all(user);
|
||||||
.where('userId', userId)
|
|
||||||
.orderBy('created_at', 'desc');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeToken(tokenId: number, userId: number): Promise<void> {
|
revokeToken(user: User, identifier: number) {
|
||||||
const token = await ApiToken.query()
|
return User.accessTokens.delete(user, identifier);
|
||||||
.where('id', tokenId)
|
|
||||||
.where('userId', userId)
|
|
||||||
.firstOrFail();
|
|
||||||
|
|
||||||
token.isActive = false;
|
|
||||||
await token.save();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateToken(tokenString: string): Promise<ApiToken | null> {
|
validateToken(token: AccessToken) {
|
||||||
const token = await ApiToken.query()
|
return User.accessTokens.verify(token.value!);
|
||||||
.where('token', tokenString)
|
}
|
||||||
.where('isActive', true)
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (!token || !token.isValid()) {
|
getTokenByValue(user: User, value: string) {
|
||||||
return null;
|
return User.accessTokens.find(user, value);
|
||||||
}
|
|
||||||
|
|
||||||
await token.markAsUsed();
|
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import vine from '@vinejs/vine';
|
|||||||
export const deleteApiTokenValidator = vine.compile(
|
export const deleteApiTokenValidator = vine.compile(
|
||||||
vine.object({
|
vine.object({
|
||||||
params: vine.object({
|
params: vine.object({
|
||||||
tokenId: vine.number().positive(),
|
tokenId: vine.string().trim().minLength(1).maxLength(255),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ export default class ShowUserSettingsController {
|
|||||||
|
|
||||||
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.getUserTokens(user.id);
|
const tokens = await this.apiTokenService.getTokens(user);
|
||||||
return inertia.render('user_settings/show', {
|
return inertia.render('user_settings/show', {
|
||||||
user,
|
user,
|
||||||
tokens,
|
tokens: tokens.map((token) => {
|
||||||
|
return {
|
||||||
|
...token.toJSON(),
|
||||||
|
identifier: token.identifier,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { defaultTableFields } from '#database/default_table_fields';
|
|
||||||
import { BaseSchema } from '@adonisjs/lucid/schema';
|
|
||||||
|
|
||||||
export default class CreateApiTokensTable extends BaseSchema {
|
|
||||||
static tableName = 'api_tokens';
|
|
||||||
|
|
||||||
async up() {
|
|
||||||
const exists = await this.schema.hasTable(CreateApiTokensTable.tableName);
|
|
||||||
if (exists) {
|
|
||||||
return console.warn(
|
|
||||||
`Table ${CreateApiTokensTable.tableName} already exists.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.schema.createTable(CreateApiTokensTable.tableName, (table) => {
|
|
||||||
table
|
|
||||||
.integer('user_id')
|
|
||||||
.unsigned()
|
|
||||||
.references('id')
|
|
||||||
.inTable('users')
|
|
||||||
.onDelete('CASCADE');
|
|
||||||
table.string('name', 255).notNullable();
|
|
||||||
table.string('token', 255).notNullable().unique();
|
|
||||||
table.timestamp('last_used_at').nullable();
|
|
||||||
table.timestamp('expires_at').nullable();
|
|
||||||
table.boolean('is_active').defaultTo(true).notNullable();
|
|
||||||
|
|
||||||
defaultTableFields(table);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async down() {
|
|
||||||
this.schema.dropTable(CreateApiTokensTable.tableName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,33 @@
|
|||||||
|
import { usePage } from '@inertiajs/react';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CopyButton,
|
CopyButton,
|
||||||
Group,
|
Group,
|
||||||
Stack,
|
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { modals } from '@mantine/modals';
|
import { modals } from '@mantine/modals';
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { TbCheck, TbCopy, TbPlus, TbTrash } from 'react-icons/tb';
|
import { TbPlus, TbTrash } from 'react-icons/tb';
|
||||||
|
import { SimpleTable } from '~/components/common/simple_table/simple_table';
|
||||||
import { useApiTokens } from '~/hooks/use_api_tokens';
|
import { useApiTokens } from '~/hooks/use_api_tokens';
|
||||||
|
import { ApiToken } from '~/types/app';
|
||||||
import { CreateTokenModal } from './create_token_modal';
|
import { CreateTokenModal } from './create_token_modal';
|
||||||
|
|
||||||
|
const useGetCreatedToken = () => {
|
||||||
|
const newlyCreatedToken = usePage<{
|
||||||
|
token?: ApiToken;
|
||||||
|
}>().props.token;
|
||||||
|
return newlyCreatedToken;
|
||||||
|
};
|
||||||
|
|
||||||
export function ApiTokens() {
|
export function ApiTokens() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { tokens, createToken, revokeToken } = useApiTokens();
|
const { tokens, createToken, revokeToken } = useApiTokens();
|
||||||
|
|
||||||
|
const newlyCreatedToken = useGetCreatedToken();
|
||||||
|
|
||||||
const handleCreateTokenModal = () => {
|
const handleCreateTokenModal = () => {
|
||||||
modals.open({
|
modals.open({
|
||||||
title: t('api-tokens.create-new'),
|
title: t('api-tokens.create-new'),
|
||||||
@@ -32,32 +40,67 @@ export function ApiTokens() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRevokeToken = async (tokenId: number, tokenName: string) => {
|
const handleRevokeToken = async (tokenId: number) => {
|
||||||
|
const token = tokens.find((t) => t.identifier === tokenId);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: t('api-tokens.revoke'),
|
title: (
|
||||||
children: (
|
<>
|
||||||
<Text size="sm">
|
{t('api-tokens.revoke')} "<strong>{token.name}</strong>"
|
||||||
{t('api-tokens.confirm-revoke')} <strong>{tokenName}</strong>?
|
</>
|
||||||
</Text>
|
|
||||||
),
|
),
|
||||||
|
children: <Text size="sm">{t('api-tokens.confirm-revoke')}</Text>,
|
||||||
labels: {
|
labels: {
|
||||||
confirm: t('api-tokens.revoke'),
|
confirm: t('api-tokens.revoke'),
|
||||||
cancel: t('common.cancel'),
|
cancel: t('cancel'),
|
||||||
},
|
},
|
||||||
confirmProps: { color: 'red' },
|
confirmProps: { color: 'red' },
|
||||||
onConfirm: () => revokeToken(tokenId),
|
onConfirm: () => revokeToken(tokenId),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string | null) => {
|
const generateTokenRow = (token: ApiToken) =>
|
||||||
if (!dateString) return t('api-tokens.never');
|
newlyCreatedToken?.identifier === token.identifier && (
|
||||||
return DateTime.fromISO(dateString).toRelative();
|
<>
|
||||||
};
|
<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 isExpired = (expiresAt: string | null) => {
|
const generateRow = (token: ApiToken) => ({
|
||||||
if (!expiresAt) return false;
|
key: token.identifier.toString(),
|
||||||
return DateTime.fromISO(expiresAt) < DateTime.now();
|
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 (
|
return (
|
||||||
<Card withBorder>
|
<Card withBorder>
|
||||||
@@ -73,87 +116,13 @@ export function ApiTokens() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Stack gap="sm">
|
{tokens.length === 0 && (
|
||||||
{tokens.length === 0 ? (
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
<Text c="dimmed" ta="center" py="xl">
|
{t('api-tokens.no-tokens')}
|
||||||
{t('api-tokens.no-tokens')}
|
</Text>
|
||||||
</Text>
|
)}
|
||||||
) : (
|
|
||||||
tokens.map((token) => (
|
{tokens.length > 0 && <SimpleTable data={rows} />}
|
||||||
<Card
|
|
||||||
key={token.id}
|
|
||||||
withBorder
|
|
||||||
p="sm"
|
|
||||||
opacity={token.isActive ? 1 : 0.5}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" align="flex-start">
|
|
||||||
<Stack gap="xs" style={{ flex: 1 }}>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Text fw={500}>{token.name}</Text>
|
|
||||||
{isExpired(token.expiresAt) && (
|
|
||||||
<Badge color="red" variant="light" size="xs">
|
|
||||||
{t('api-tokens.expired')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{!token.isActive && (
|
|
||||||
<Badge color="gray" variant="light" size="xs">
|
|
||||||
{t('api-tokens.revoked')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t('api-tokens.created')}: {formatDate(token.createdAt)}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t('api-tokens.last-used')}: {formatDate(token.lastUsedAt)}
|
|
||||||
</Text>
|
|
||||||
{token.expiresAt && (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t('api-tokens.expires')}: {formatDate(token.expiresAt)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
{token.isActive && (
|
|
||||||
<Group gap="xs">
|
|
||||||
<CopyButton value={token.token} timeout={2000}>
|
|
||||||
{({ copied, copy }) => (
|
|
||||||
<Tooltip
|
|
||||||
label={
|
|
||||||
copied
|
|
||||||
? t('api-tokens.copied')
|
|
||||||
: t('api-tokens.copy')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
color={copied ? 'teal' : 'blue'}
|
|
||||||
onClick={copy}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<TbCheck size={16} />
|
|
||||||
) : (
|
|
||||||
<TbCopy size={16} />
|
|
||||||
)}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</CopyButton>
|
|
||||||
<Tooltip label={t('api-tokens.revoke')}>
|
|
||||||
<ActionIcon
|
|
||||||
color="red"
|
|
||||||
variant="subtle"
|
|
||||||
onClick={() => handleRevokeToken(token.id, token.name)}
|
|
||||||
>
|
|
||||||
<TbTrash size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function CreateTokenModal({ onCreate, onClose }: CreateTokenModalProps) {
|
|||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="subtle" onClick={onClose}>
|
<Button variant="subtle" onClick={onClose}>
|
||||||
{t('common.cancel')}
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
|
|||||||
@@ -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,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,5 @@
|
|||||||
import { router, usePage } from '@inertiajs/react';
|
import { router, usePage } from '@inertiajs/react';
|
||||||
|
import { ApiToken } from '~/types/app';
|
||||||
interface ApiToken {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
token: string;
|
|
||||||
lastUsedAt: string | null;
|
|
||||||
expiresAt: string | null;
|
|
||||||
isActive: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useApiTokens() {
|
export function useApiTokens() {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"created": "Created",
|
"created": "Created",
|
||||||
"last-used": "Last used",
|
"last-used": "Last used",
|
||||||
"expires": "Expires",
|
"expires": "Expires",
|
||||||
"never": "Never"
|
"never": "Never",
|
||||||
|
"new-token": "New token created"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"created": "Créé",
|
"created": "Créé",
|
||||||
"last-used": "Dernière utilisation",
|
"last-used": "Dernière utilisation",
|
||||||
"expires": "Expire",
|
"expires": "Expire",
|
||||||
"never": "Jamais"
|
"never": "Jamais",
|
||||||
|
"new-token": "Nouveau token créé"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user