mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2026-04-05 17:15:40 +00:00
feat(backend): allow to authorize via api key, interfaces to create and manage api keys (#9482)
This commit is contained in:
parent
0bf724f0c0
commit
ceb46263e4
@ -49,3 +49,5 @@ IMAGOR_SECRET=mysecret
|
||||
CATEGORIES_ACTIVE=false
|
||||
MAX_PINNED_POSTS=1
|
||||
MAX_GROUP_PINNED_POSTS=1
|
||||
API_KEYS_ENABLED=false
|
||||
API_KEYS_MAX_PER_USER=5
|
||||
|
||||
@ -41,3 +41,5 @@ IMAGOR_SECRET=mysecret
|
||||
CATEGORIES_ACTIVE=false
|
||||
MAX_PINNED_POSTS=1
|
||||
MAX_GROUP_PINNED_POSTS=1
|
||||
API_KEYS_ENABLED=true
|
||||
API_KEYS_MAX_PER_USER=5
|
||||
|
||||
@ -148,6 +148,8 @@ const options = {
|
||||
MAX_GROUP_PINNED_POSTS: Number.isNaN(Number(process.env.MAX_GROUP_PINNED_POSTS))
|
||||
? 1
|
||||
: Number(process.env.MAX_GROUP_PINNED_POSTS),
|
||||
API_KEYS_ENABLED: env.API_KEYS_ENABLED === 'true',
|
||||
API_KEYS_MAX_PER_USER: (env.API_KEYS_MAX_PER_USER && parseInt(env.API_KEYS_MAX_PER_USER)) || 5,
|
||||
}
|
||||
|
||||
const language = {
|
||||
|
||||
16
backend/src/db/models/ApiKey.ts
Normal file
16
backend/src/db/models/ApiKey.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export default {
|
||||
id: { type: 'uuid', primary: true },
|
||||
name: { type: 'string', required: true },
|
||||
keyHash: { type: 'string', unique: 'true', required: true },
|
||||
keyPrefix: { type: 'string', required: true },
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
lastUsedAt: { type: 'string', isoDate: true },
|
||||
expiresAt: { type: 'string', isoDate: true },
|
||||
disabled: { type: 'boolean', default: false },
|
||||
owner: {
|
||||
type: 'relationship',
|
||||
relationship: 'HAS_API_KEY',
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
// We use static imports instead of dynamic require() to ensure compatibility
|
||||
// with both Node.js and Webpack (used by Cypress cucumber preprocessor).
|
||||
|
||||
import ApiKey from './ApiKey'
|
||||
import Badge from './Badge'
|
||||
import Category from './Category'
|
||||
import Comment from './Comment'
|
||||
@ -29,6 +30,7 @@ import type Neode from 'neode'
|
||||
// SchemaObject type with PropertyTypes union. The Neode type definitions are
|
||||
// incomplete/incorrect, so we use double assertion to bypass the check.
|
||||
export default {
|
||||
ApiKey,
|
||||
Badge,
|
||||
Category,
|
||||
Comment,
|
||||
|
||||
@ -828,6 +828,118 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
},
|
||||
)
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('seed', 'api-keys')
|
||||
|
||||
// API Keys for Peter (admin) — active keys
|
||||
await database.write({
|
||||
query: `MATCH (u:User { id: 'u1' })
|
||||
CREATE (u)-[:HAS_API_KEY]->(k:ApiKey {
|
||||
id: 'ak-peter-ci', name: 'CI Bot', keyPrefix: 'oak_peterCI',
|
||||
keyHash: 'seed-hash-peter-ci', createdAt: toString(datetime() - duration('P30D')),
|
||||
lastUsedAt: toString(datetime() - duration('PT2H')), disabled: false
|
||||
})`,
|
||||
variables: {},
|
||||
})
|
||||
await database.write({
|
||||
query: `MATCH (u:User { id: 'u1' })
|
||||
CREATE (u)-[:HAS_API_KEY]->(k:ApiKey {
|
||||
id: 'ak-peter-backup', name: 'Backup Script', keyPrefix: 'oak_peterBU',
|
||||
keyHash: 'seed-hash-peter-backup', createdAt: toString(datetime() - duration('P14D')),
|
||||
lastUsedAt: toString(datetime() - duration('P3D')), disabled: false
|
||||
})`,
|
||||
variables: {},
|
||||
})
|
||||
// Peter's revoked key
|
||||
await database.write({
|
||||
query: `MATCH (u:User { id: 'u1' })
|
||||
CREATE (u)-[:HAS_API_KEY]->(k:ApiKey {
|
||||
id: 'ak-peter-old', name: 'Old Script', keyPrefix: 'oak_peterOL',
|
||||
keyHash: 'seed-hash-peter-old', createdAt: '2025-01-01T00:00:00.000Z',
|
||||
lastUsedAt: '2025-05-15T00:00:00.000Z',
|
||||
disabled: true, disabledAt: '2025-06-01T00:00:00.000Z'
|
||||
})`,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
// API Key for Jenny (user) — active, with expiry
|
||||
await database.write({
|
||||
query: `MATCH (u:User { id: 'u3' })
|
||||
CREATE (u)-[:HAS_API_KEY]->(k:ApiKey {
|
||||
id: 'ak-jenny-bot', name: 'Weather Bot', keyPrefix: 'oak_jennyWB',
|
||||
keyHash: 'seed-hash-jenny-bot', createdAt: toString(datetime() - duration('P7D')),
|
||||
lastUsedAt: toString(datetime() - duration('PT30M')), disabled: false,
|
||||
expiresAt: toString(datetime() + duration('P365D'))
|
||||
})`,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
// API Key for Huey (user) — active, all his posts created via this key
|
||||
await database.write({
|
||||
query: `MATCH (u:User { id: 'u4' })
|
||||
CREATE (u)-[:HAS_API_KEY]->(k:ApiKey {
|
||||
id: 'ak-huey-auto', name: 'Auto Publisher', keyPrefix: 'oak_hueyAUT',
|
||||
keyHash: 'seed-hash-huey-auto', createdAt: toString(datetime() - duration('P60D')),
|
||||
lastUsedAt: toString(datetime() - duration('PT5M')), disabled: false
|
||||
})`,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
// API Key for Bob (moderator) — active
|
||||
await database.write({
|
||||
query: `MATCH (u:User { id: 'u2' })
|
||||
CREATE (u)-[:HAS_API_KEY]->(k:ApiKey {
|
||||
id: 'ak-bob-monitor', name: 'Monitoring', keyPrefix: 'oak_bobMON',
|
||||
keyHash: 'seed-hash-bob-monitor', createdAt: toString(datetime()),
|
||||
disabled: false
|
||||
})`,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
// Create some posts via API key (Peter's CI Bot)
|
||||
authenticatedUser = { ...(await peterLustig.toJson()), apiKeyId: 'ak-peter-ci' }
|
||||
await mutate({
|
||||
mutation: CreatePost,
|
||||
variables: {
|
||||
id: 'p-api-1',
|
||||
title: 'Automated Daily Report',
|
||||
content: 'This post was created automatically via API key by the CI Bot.',
|
||||
categoryIds: ['cat16'],
|
||||
},
|
||||
})
|
||||
await mutate({
|
||||
mutation: CreatePost,
|
||||
variables: {
|
||||
id: 'p-api-2',
|
||||
title: 'Weekly Statistics Summary',
|
||||
content: 'Automated weekly summary of community statistics.',
|
||||
categoryIds: ['cat9'],
|
||||
},
|
||||
})
|
||||
|
||||
// Jenny's Weather Bot creates a post
|
||||
authenticatedUser = { ...(await jennyRostock.toJson()), apiKeyId: 'ak-jenny-bot' }
|
||||
await mutate({
|
||||
mutation: CreatePost,
|
||||
variables: {
|
||||
id: 'p-api-3',
|
||||
title: 'Weather Report Paris',
|
||||
content: 'Sunny, 22°C. Perfect day for a walk along the Seine.',
|
||||
categoryIds: ['cat4'],
|
||||
},
|
||||
})
|
||||
// Jenny's bot also comments
|
||||
await mutate({
|
||||
mutation: CreateComment,
|
||||
variables: {
|
||||
id: 'c-api-1',
|
||||
postId: 'p-api-1',
|
||||
content: 'Automated cross-reference: See also the weather report.',
|
||||
},
|
||||
})
|
||||
|
||||
authenticatedUser = null
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('seed', 'invitecodes')
|
||||
|
||||
@ -1753,6 +1865,14 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
)
|
||||
}
|
||||
|
||||
// Mark all Huey's posts and comments as created via API key
|
||||
await database.write({
|
||||
query: `MATCH (u:User { id: 'u4' })-[:WROTE]->(content)
|
||||
WHERE content:Post OR content:Comment
|
||||
SET content.createdByApiKey = 'ak-huey-auto'`,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
await Factory.build('donations')
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
mutation adminRevokeApiKey($id: ID!) {
|
||||
adminRevokeApiKey(id: $id)
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
mutation adminRevokeUserApiKeys($userId: ID!) {
|
||||
adminRevokeUserApiKeys(userId: $userId)
|
||||
}
|
||||
14
backend/src/graphql/queries/apiKeys/apiKeyUsers.gql
Normal file
14
backend/src/graphql/queries/apiKeys/apiKeyUsers.gql
Normal file
@ -0,0 +1,14 @@
|
||||
query apiKeyUsers($first: Int, $offset: Int) {
|
||||
apiKeyUsers(first: $first, offset: $offset) {
|
||||
user {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
||||
activeCount
|
||||
revokedCount
|
||||
postsCount
|
||||
commentsCount
|
||||
lastActivity
|
||||
}
|
||||
}
|
||||
12
backend/src/graphql/queries/apiKeys/apiKeysForUser.gql
Normal file
12
backend/src/graphql/queries/apiKeys/apiKeysForUser.gql
Normal file
@ -0,0 +1,12 @@
|
||||
query apiKeysForUser($userId: ID!) {
|
||||
apiKeysForUser(userId: $userId) {
|
||||
id
|
||||
name
|
||||
keyPrefix
|
||||
createdAt
|
||||
lastUsedAt
|
||||
expiresAt
|
||||
disabled
|
||||
disabledAt
|
||||
}
|
||||
}
|
||||
15
backend/src/graphql/queries/apiKeys/createApiKey.gql
Normal file
15
backend/src/graphql/queries/apiKeys/createApiKey.gql
Normal file
@ -0,0 +1,15 @@
|
||||
mutation createApiKey($name: String!, $expiresInDays: Int) {
|
||||
createApiKey(name: $name, expiresInDays: $expiresInDays) {
|
||||
apiKey {
|
||||
id
|
||||
name
|
||||
keyPrefix
|
||||
createdAt
|
||||
expiresAt
|
||||
disabled
|
||||
disabledAt
|
||||
lastUsedAt
|
||||
}
|
||||
secret
|
||||
}
|
||||
}
|
||||
12
backend/src/graphql/queries/apiKeys/myApiKeys.gql
Normal file
12
backend/src/graphql/queries/apiKeys/myApiKeys.gql
Normal file
@ -0,0 +1,12 @@
|
||||
query myApiKeys {
|
||||
myApiKeys {
|
||||
id
|
||||
name
|
||||
keyPrefix
|
||||
createdAt
|
||||
lastUsedAt
|
||||
expiresAt
|
||||
disabled
|
||||
disabledAt
|
||||
}
|
||||
}
|
||||
3
backend/src/graphql/queries/apiKeys/revokeApiKey.gql
Normal file
3
backend/src/graphql/queries/apiKeys/revokeApiKey.gql
Normal file
@ -0,0 +1,3 @@
|
||||
mutation revokeApiKey($id: ID!) {
|
||||
revokeApiKey(id: $id)
|
||||
}
|
||||
12
backend/src/graphql/queries/apiKeys/updateApiKey.gql
Normal file
12
backend/src/graphql/queries/apiKeys/updateApiKey.gql
Normal file
@ -0,0 +1,12 @@
|
||||
mutation updateApiKey($id: ID!, $name: String!) {
|
||||
updateApiKey(id: $id, name: $name) {
|
||||
id
|
||||
name
|
||||
keyPrefix
|
||||
createdAt
|
||||
lastUsedAt
|
||||
expiresAt
|
||||
disabled
|
||||
disabledAt
|
||||
}
|
||||
}
|
||||
493
backend/src/graphql/resolvers/apiKeys.spec.ts
Normal file
493
backend/src/graphql/resolvers/apiKeys.spec.ts
Normal file
@ -0,0 +1,493 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { cleanDatabase } from '@db/factories'
|
||||
import adminRevokeApiKey from '@graphql/queries/apiKeys/adminRevokeApiKey.gql'
|
||||
import adminRevokeUserApiKeys from '@graphql/queries/apiKeys/adminRevokeUserApiKeys.gql'
|
||||
import apiKeysForUser from '@graphql/queries/apiKeys/apiKeysForUser.gql'
|
||||
import apiKeyUsers from '@graphql/queries/apiKeys/apiKeyUsers.gql'
|
||||
import createApiKey from '@graphql/queries/apiKeys/createApiKey.gql'
|
||||
import myApiKeys from '@graphql/queries/apiKeys/myApiKeys.gql'
|
||||
import revokeApiKey from '@graphql/queries/apiKeys/revokeApiKey.gql'
|
||||
import updateApiKey from '@graphql/queries/apiKeys/updateApiKey.gql'
|
||||
import { createApolloTestSetup } from '@root/test/helpers'
|
||||
|
||||
import type { ApolloTestSetup } from '@root/test/helpers'
|
||||
import type { Context } from '@src/context'
|
||||
|
||||
let authenticatedUser: Context['user']
|
||||
const context = () => ({
|
||||
authenticatedUser,
|
||||
config: { API_KEYS_ENABLED: true, API_KEYS_MAX_PER_USER: 3 },
|
||||
})
|
||||
let query: ApolloTestSetup['query']
|
||||
let mutate: ApolloTestSetup['mutate']
|
||||
let database: ApolloTestSetup['database']
|
||||
let server: ApolloTestSetup['server']
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanDatabase()
|
||||
const apolloSetup = await createApolloTestSetup({ context })
|
||||
query = apolloSetup.query
|
||||
mutate = apolloSetup.mutate
|
||||
database = apolloSetup.database
|
||||
server = apolloSetup.server
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDatabase()
|
||||
void server.stop()
|
||||
void database.driver.close()
|
||||
database.neode.close()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanDatabase()
|
||||
})
|
||||
|
||||
describe('createApiKey', () => {
|
||||
describe('unauthenticated', () => {
|
||||
beforeEach(() => {
|
||||
authenticatedUser = null
|
||||
})
|
||||
|
||||
it('throws authorization error', async () => {
|
||||
const { errors } = await mutate({
|
||||
mutation: createApiKey,
|
||||
variables: { name: 'Test Key' },
|
||||
})
|
||||
expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated', () => {
|
||||
beforeEach(async () => {
|
||||
const user = await database.neode.create('User', {
|
||||
id: 'u1',
|
||||
name: 'Test User',
|
||||
slug: 'test-user',
|
||||
role: 'user',
|
||||
})
|
||||
authenticatedUser = (await user.toJson()) as Context['user']
|
||||
})
|
||||
|
||||
it('creates an API key and returns secret with oak_ prefix', async () => {
|
||||
const { data, errors } = await mutate({
|
||||
mutation: createApiKey,
|
||||
variables: { name: 'My CI Key' },
|
||||
})
|
||||
expect(errors).toBeUndefined()
|
||||
expect(data.createApiKey.secret).toMatch(/^oak_/)
|
||||
expect(data.createApiKey.apiKey).toMatchObject({
|
||||
name: 'My CI Key',
|
||||
disabled: false,
|
||||
disabledAt: null,
|
||||
lastUsedAt: null,
|
||||
})
|
||||
expect(data.createApiKey.apiKey.keyPrefix).toMatch(/^oak_/)
|
||||
expect(data.createApiKey.apiKey.id).toBeTruthy()
|
||||
expect(data.createApiKey.apiKey.createdAt).toBeTruthy()
|
||||
})
|
||||
|
||||
it('creates a key with expiry', async () => {
|
||||
const { data, errors } = await mutate({
|
||||
mutation: createApiKey,
|
||||
variables: { name: 'Expiring Key', expiresInDays: 30 },
|
||||
})
|
||||
expect(errors).toBeUndefined()
|
||||
expect(data.createApiKey.apiKey.expiresAt).toBeTruthy()
|
||||
})
|
||||
|
||||
it('creates a key without expiry', async () => {
|
||||
const { data, errors } = await mutate({
|
||||
mutation: createApiKey,
|
||||
variables: { name: 'Permanent Key' },
|
||||
})
|
||||
expect(errors).toBeUndefined()
|
||||
expect(data.createApiKey.apiKey.expiresAt).toBeNull()
|
||||
})
|
||||
|
||||
it('enforces max keys per user', async () => {
|
||||
await mutate({ mutation: createApiKey, variables: { name: 'Key 1' } })
|
||||
await mutate({ mutation: createApiKey, variables: { name: 'Key 2' } })
|
||||
await mutate({ mutation: createApiKey, variables: { name: 'Key 3' } })
|
||||
const { errors } = await mutate({
|
||||
mutation: createApiKey,
|
||||
variables: { name: 'Key 4' },
|
||||
})
|
||||
expect(errors?.[0].message).toContain('Maximum of 3 active API keys reached')
|
||||
})
|
||||
|
||||
it('rejects expiresInDays of 0', async () => {
|
||||
const { errors } = await mutate({
|
||||
mutation: createApiKey,
|
||||
variables: { name: 'Zero Expiry', expiresInDays: 0 },
|
||||
})
|
||||
expect(errors?.[0].message).toContain('expiresInDays must be a positive integer')
|
||||
})
|
||||
|
||||
it('rejects negative expiresInDays', async () => {
|
||||
const { errors } = await mutate({
|
||||
mutation: createApiKey,
|
||||
variables: { name: 'Negative Expiry', expiresInDays: -5 },
|
||||
})
|
||||
expect(errors?.[0].message).toContain('expiresInDays must be a positive integer')
|
||||
})
|
||||
|
||||
it('does not count revoked keys towards the limit', async () => {
|
||||
const { data: k1 } = await mutate({ mutation: createApiKey, variables: { name: 'Key 1' } })
|
||||
await mutate({ mutation: createApiKey, variables: { name: 'Key 2' } })
|
||||
await mutate({ mutation: createApiKey, variables: { name: 'Key 3' } })
|
||||
// Revoke one
|
||||
await mutate({ mutation: revokeApiKey, variables: { id: k1.createApiKey.apiKey.id } })
|
||||
// Should succeed now
|
||||
const { errors } = await mutate({ mutation: createApiKey, variables: { name: 'Key 4' } })
|
||||
expect(errors).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API keys disabled', () => {
|
||||
it('throws error when feature is disabled', async () => {
|
||||
const user = await database.neode.create('User', {
|
||||
id: 'u-disabled',
|
||||
name: 'Disabled User',
|
||||
slug: 'disabled-user',
|
||||
role: 'user',
|
||||
})
|
||||
authenticatedUser = (await user.toJson()) as Context['user']
|
||||
const contextDisabled = () => ({
|
||||
authenticatedUser,
|
||||
config: { API_KEYS_ENABLED: false, API_KEYS_MAX_PER_USER: 5 },
|
||||
})
|
||||
const setup = await createApolloTestSetup({ context: contextDisabled })
|
||||
const { errors } = await setup.mutate({
|
||||
mutation: createApiKey,
|
||||
variables: { name: 'Should Fail' },
|
||||
})
|
||||
expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!')
|
||||
void setup.server.stop()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('myApiKeys', () => {
|
||||
beforeEach(async () => {
|
||||
const user = await database.neode.create('User', {
|
||||
id: 'u2',
|
||||
name: 'Key Owner',
|
||||
slug: 'key-owner',
|
||||
role: 'user',
|
||||
})
|
||||
authenticatedUser = (await user.toJson()) as Context['user']
|
||||
})
|
||||
|
||||
it('returns empty list when no keys exist', async () => {
|
||||
const { data, errors } = await query({ query: myApiKeys })
|
||||
expect(errors).toBeUndefined()
|
||||
expect(data.myApiKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('returns created keys ordered by createdAt desc', async () => {
|
||||
await mutate({ mutation: createApiKey, variables: { name: 'Key A' } })
|
||||
await mutate({ mutation: createApiKey, variables: { name: 'Key B' } })
|
||||
const { data, errors } = await query({ query: myApiKeys })
|
||||
expect(errors).toBeUndefined()
|
||||
expect(data.myApiKeys).toHaveLength(2)
|
||||
// Most recent first
|
||||
expect(data.myApiKeys[0].name).toBe('Key B')
|
||||
expect(data.myApiKeys[1].name).toBe('Key A')
|
||||
})
|
||||
|
||||
it('includes revoked keys', async () => {
|
||||
const { data: created } = await mutate({
|
||||
mutation: createApiKey,
|
||||
variables: { name: 'To Revoke' },
|
||||
})
|
||||
await mutate({ mutation: revokeApiKey, variables: { id: created.createApiKey.apiKey.id } })
|
||||
const { data } = await query({ query: myApiKeys })
|
||||
expect(data.myApiKeys).toHaveLength(1)
|
||||
expect(data.myApiKeys[0].disabled).toBe(true)
|
||||
expect(data.myApiKeys[0].disabledAt).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateApiKey', () => {
|
||||
let keyId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
const user = await database.neode.create('User', {
|
||||
id: 'u3',
|
||||
name: 'Updater',
|
||||
slug: 'updater',
|
||||
role: 'user',
|
||||
})
|
||||
authenticatedUser = (await user.toJson()) as Context['user']
|
||||
const { data } = await mutate({ mutation: createApiKey, variables: { name: 'Original' } })
|
||||
keyId = data.createApiKey.apiKey.id
|
||||
})
|
||||
|
||||
it('renames a key', async () => {
|
||||
const { data, errors } = await mutate({
|
||||
mutation: updateApiKey,
|
||||
variables: { id: keyId, name: 'Renamed' },
|
||||
})
|
||||
expect(errors).toBeUndefined()
|
||||
expect(data.updateApiKey.name).toBe('Renamed')
|
||||
})
|
||||
|
||||
it('throws error for nonexistent key', async () => {
|
||||
const { errors } = await mutate({
|
||||
mutation: updateApiKey,
|
||||
variables: { id: 'nonexistent', name: 'Fail' },
|
||||
})
|
||||
expect(errors?.[0].message).toContain('API key not found')
|
||||
})
|
||||
|
||||
it("throws error for another user's key", async () => {
|
||||
const otherUser = await database.neode.create('User', {
|
||||
id: 'u-stranger',
|
||||
name: 'Stranger',
|
||||
slug: 'stranger',
|
||||
role: 'user',
|
||||
})
|
||||
authenticatedUser = (await otherUser.toJson()) as Context['user']
|
||||
const { errors } = await mutate({
|
||||
mutation: updateApiKey,
|
||||
variables: { id: keyId, name: 'Stolen' },
|
||||
})
|
||||
expect(errors?.[0].message).toContain('API key not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('revokeApiKey', () => {
|
||||
let keyId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
const user = await database.neode.create('User', {
|
||||
id: 'u4',
|
||||
name: 'Revoker',
|
||||
slug: 'revoker',
|
||||
role: 'user',
|
||||
})
|
||||
authenticatedUser = (await user.toJson()) as Context['user']
|
||||
const { data } = await mutate({ mutation: createApiKey, variables: { name: 'To Revoke' } })
|
||||
keyId = data.createApiKey.apiKey.id
|
||||
})
|
||||
|
||||
it('revokes own key', async () => {
|
||||
const { data, errors } = await mutate({ mutation: revokeApiKey, variables: { id: keyId } })
|
||||
expect(errors).toBeUndefined()
|
||||
expect(data.revokeApiKey).toBe(true)
|
||||
})
|
||||
|
||||
it('sets disabledAt on revoked key', async () => {
|
||||
await mutate({ mutation: revokeApiKey, variables: { id: keyId } })
|
||||
const { data } = await query({ query: myApiKeys })
|
||||
const revoked = data.myApiKeys.find((k) => k.id === keyId)
|
||||
expect(revoked.disabled).toBe(true)
|
||||
expect(revoked.disabledAt).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns false for nonexistent key', async () => {
|
||||
const { data } = await mutate({ mutation: revokeApiKey, variables: { id: 'nonexistent' } })
|
||||
expect(data.revokeApiKey).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when revoking an already revoked key', async () => {
|
||||
await mutate({ mutation: revokeApiKey, variables: { id: keyId } })
|
||||
const { data } = await mutate({ mutation: revokeApiKey, variables: { id: keyId } })
|
||||
expect(data.revokeApiKey).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves original disabledAt on double revoke', async () => {
|
||||
await mutate({ mutation: revokeApiKey, variables: { id: keyId } })
|
||||
const { data: before } = await query({ query: myApiKeys })
|
||||
const originalDisabledAt = before.myApiKeys.find((k) => k.id === keyId).disabledAt
|
||||
await mutate({ mutation: revokeApiKey, variables: { id: keyId } })
|
||||
const { data: after } = await query({ query: myApiKeys })
|
||||
expect(after.myApiKeys.find((k) => k.id === keyId).disabledAt).toBe(originalDisabledAt)
|
||||
})
|
||||
|
||||
it("returns false for another user's key", async () => {
|
||||
const otherUser = await database.neode.create('User', {
|
||||
id: 'u4-other',
|
||||
name: 'Other',
|
||||
slug: 'other',
|
||||
role: 'user',
|
||||
})
|
||||
authenticatedUser = (await otherUser.toJson()) as Context['user']
|
||||
const { data } = await mutate({ mutation: revokeApiKey, variables: { id: keyId } })
|
||||
expect(data.revokeApiKey).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin operations', () => {
|
||||
let regularUser, adminUser
|
||||
let keyId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
regularUser = await database.neode.create('User', {
|
||||
id: 'u-regular',
|
||||
name: 'Regular',
|
||||
slug: 'regular',
|
||||
role: 'user',
|
||||
})
|
||||
adminUser = await database.neode.create('User', {
|
||||
id: 'u-admin',
|
||||
name: 'Admin',
|
||||
slug: 'admin',
|
||||
role: 'admin',
|
||||
})
|
||||
authenticatedUser = (await regularUser.toJson()) as Context['user']
|
||||
const { data } = await mutate({ mutation: createApiKey, variables: { name: 'Regular Key' } })
|
||||
keyId = data.createApiKey.apiKey.id
|
||||
})
|
||||
|
||||
describe('adminRevokeApiKey', () => {
|
||||
it('non-admin cannot revoke', async () => {
|
||||
authenticatedUser = (await regularUser.toJson()) as Context['user']
|
||||
const { errors } = await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } })
|
||||
expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!')
|
||||
})
|
||||
|
||||
it('admin can revoke any key', async () => {
|
||||
authenticatedUser = (await adminUser.toJson()) as Context['user']
|
||||
const { data, errors } = await mutate({
|
||||
mutation: adminRevokeApiKey,
|
||||
variables: { id: keyId },
|
||||
})
|
||||
expect(errors).toBeUndefined()
|
||||
expect(data.adminRevokeApiKey).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when admin revokes an already revoked key', async () => {
|
||||
authenticatedUser = (await adminUser.toJson()) as Context['user']
|
||||
await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } })
|
||||
const { data } = await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } })
|
||||
expect(data.adminRevokeApiKey).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('adminRevokeUserApiKeys', () => {
|
||||
beforeEach(async () => {
|
||||
authenticatedUser = (await regularUser.toJson()) as Context['user']
|
||||
await mutate({ mutation: createApiKey, variables: { name: 'Key 2' } })
|
||||
})
|
||||
|
||||
it('non-admin cannot bulk revoke', async () => {
|
||||
authenticatedUser = (await regularUser.toJson()) as Context['user']
|
||||
const { errors } = await mutate({
|
||||
mutation: adminRevokeUserApiKeys,
|
||||
variables: { userId: 'u-regular' },
|
||||
})
|
||||
expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!')
|
||||
})
|
||||
|
||||
it('admin can revoke all keys of a user', async () => {
|
||||
authenticatedUser = (await adminUser.toJson()) as Context['user']
|
||||
const { data, errors } = await mutate({
|
||||
mutation: adminRevokeUserApiKeys,
|
||||
variables: { userId: 'u-regular' },
|
||||
})
|
||||
expect(errors).toBeUndefined()
|
||||
expect(data.adminRevokeUserApiKeys).toBe(2)
|
||||
})
|
||||
|
||||
it('returns 0 when user has no active keys', async () => {
|
||||
authenticatedUser = (await adminUser.toJson()) as Context['user']
|
||||
const { data } = await mutate({
|
||||
mutation: adminRevokeUserApiKeys,
|
||||
variables: { userId: 'u-admin' },
|
||||
})
|
||||
expect(data.adminRevokeUserApiKeys).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('apiKeyUsers', () => {
|
||||
it('non-admin cannot access', async () => {
|
||||
authenticatedUser = (await regularUser.toJson()) as Context['user']
|
||||
const { errors } = await query({ query: apiKeyUsers, variables: { first: 10, offset: 0 } })
|
||||
expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!')
|
||||
})
|
||||
|
||||
it('admin sees users with key stats', async () => {
|
||||
authenticatedUser = (await adminUser.toJson()) as Context['user']
|
||||
const { data, errors } = await query({
|
||||
query: apiKeyUsers,
|
||||
variables: { first: 10, offset: 0 },
|
||||
})
|
||||
expect(errors).toBeUndefined()
|
||||
expect(data.apiKeyUsers.length).toBeGreaterThanOrEqual(1)
|
||||
const entry = data.apiKeyUsers.find((e) => e.user.id === 'u-regular')
|
||||
expect(entry).toBeTruthy()
|
||||
expect(entry.activeCount).toBe(1)
|
||||
expect(entry.revokedCount).toBe(0)
|
||||
expect(entry).toHaveProperty('postsCount')
|
||||
expect(entry).toHaveProperty('commentsCount')
|
||||
})
|
||||
|
||||
it('counts active and revoked keys correctly', async () => {
|
||||
// Revoke one of regular's keys
|
||||
authenticatedUser = (await adminUser.toJson()) as Context['user']
|
||||
await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } })
|
||||
const { data } = await query({ query: apiKeyUsers, variables: { first: 10, offset: 0 } })
|
||||
const entry = data.apiKeyUsers.find((e) => e.user.id === 'u-regular')
|
||||
expect(entry.activeCount).toBe(0)
|
||||
expect(entry.revokedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('supports pagination', async () => {
|
||||
authenticatedUser = (await adminUser.toJson()) as Context['user']
|
||||
const { data } = await query({ query: apiKeyUsers, variables: { first: 1, offset: 0 } })
|
||||
expect(data.apiKeyUsers).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('apiKeysForUser', () => {
|
||||
it('non-admin cannot access', async () => {
|
||||
authenticatedUser = (await regularUser.toJson()) as Context['user']
|
||||
const { errors } = await query({
|
||||
query: apiKeysForUser,
|
||||
variables: { userId: 'u-regular' },
|
||||
})
|
||||
expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!')
|
||||
})
|
||||
|
||||
it('admin sees all keys for a user', async () => {
|
||||
authenticatedUser = (await adminUser.toJson()) as Context['user']
|
||||
const { data, errors } = await query({
|
||||
query: apiKeysForUser,
|
||||
variables: { userId: 'u-regular' },
|
||||
})
|
||||
expect(errors).toBeUndefined()
|
||||
expect(data.apiKeysForUser).toHaveLength(1)
|
||||
data.apiKeysForUser.forEach((k) => {
|
||||
expect(k).toHaveProperty('id')
|
||||
expect(k).toHaveProperty('name')
|
||||
expect(k).toHaveProperty('keyPrefix')
|
||||
expect(k).toHaveProperty('disabled')
|
||||
expect(k).toHaveProperty('disabledAt')
|
||||
expect(k).toHaveProperty('lastUsedAt')
|
||||
expect(k).toHaveProperty('expiresAt')
|
||||
})
|
||||
})
|
||||
|
||||
it('returns active keys before revoked keys', async () => {
|
||||
// Create a second key so we have one active + one revoked
|
||||
authenticatedUser = (await regularUser.toJson()) as Context['user']
|
||||
await mutate({ mutation: createApiKey, variables: { name: 'Key 2' } })
|
||||
authenticatedUser = (await adminUser.toJson()) as Context['user']
|
||||
await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } })
|
||||
const { data } = await query({ query: apiKeysForUser, variables: { userId: 'u-regular' } })
|
||||
expect(data.apiKeysForUser).toHaveLength(2)
|
||||
expect(data.apiKeysForUser[0].disabled).toBe(false)
|
||||
expect(data.apiKeysForUser[1].disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty array for user without keys', async () => {
|
||||
authenticatedUser = (await adminUser.toJson()) as Context['user']
|
||||
const { data } = await query({ query: apiKeysForUser, variables: { userId: 'u-admin' } })
|
||||
expect(data.apiKeysForUser).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
215
backend/src/graphql/resolvers/apiKeys.ts
Normal file
215
backend/src/graphql/resolvers/apiKeys.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import type { Context } from '@src/context'
|
||||
import type { Integer, Record as Neo4jRecord } from 'neo4j-driver'
|
||||
|
||||
interface ApiKeyArgs {
|
||||
id?: string
|
||||
name?: string
|
||||
expiresInDays?: number
|
||||
userId?: string
|
||||
first?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
function normalizeApiKey(raw: Record<string, unknown>) {
|
||||
return {
|
||||
...raw,
|
||||
lastUsedAt: raw.lastUsedAt ?? null,
|
||||
expiresAt: raw.expiresAt ?? null,
|
||||
disabledAt: raw.disabledAt ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function generateApiKey(): { key: string; hash: string; prefix: string } {
|
||||
const bytes = randomBytes(32)
|
||||
const key = 'oak_' + bytes.toString('base64url')
|
||||
const hash = createHash('sha256').update(key).digest('hex')
|
||||
const prefix = key.slice(0, 12) // "oak_" + 8 chars
|
||||
return { key, hash, prefix }
|
||||
}
|
||||
|
||||
function toNumber(value: Integer | number): number {
|
||||
return typeof value === 'number' ? value : value.toNumber()
|
||||
}
|
||||
|
||||
function getRecord(record: Neo4jRecord, field: string): Record<string, unknown> {
|
||||
return record.get(field) as Record<string, unknown>
|
||||
}
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
myApiKeys: async (_parent: unknown, _args: unknown, context: Context) => {
|
||||
const result = await context.database.query({
|
||||
query: `
|
||||
MATCH (u:User { id: $userId })-[:HAS_API_KEY]->(k:ApiKey)
|
||||
RETURN k {.*}
|
||||
ORDER BY k.createdAt DESC
|
||||
`,
|
||||
variables: { userId: context.user?.id },
|
||||
})
|
||||
return result.records.map((r: Neo4jRecord) => normalizeApiKey(getRecord(r, 'k')))
|
||||
},
|
||||
|
||||
apiKeyUsers: async (_parent: unknown, args: ApiKeyArgs, context: Context) => {
|
||||
const first = args.first ?? 50
|
||||
const offset = args.offset ?? 0
|
||||
|
||||
const result = await context.database.query({
|
||||
query: `
|
||||
MATCH (u:User)-[:HAS_API_KEY]->(k:ApiKey)
|
||||
WITH u, collect(k) AS keys
|
||||
WITH u, keys,
|
||||
size([k IN keys WHERE NOT k.disabled]) AS activeCount,
|
||||
size([k IN keys WHERE k.disabled]) AS revokedCount,
|
||||
reduce(m = null, k IN keys | CASE WHEN k.lastUsedAt IS NOT NULL AND (m IS NULL OR k.lastUsedAt > m) THEN k.lastUsedAt ELSE m END) AS lastActivity
|
||||
ORDER BY lastActivity IS NULL, lastActivity DESC
|
||||
SKIP toInteger($offset) LIMIT toInteger($first)
|
||||
WITH u, keys, activeCount, revokedCount, lastActivity,
|
||||
[k IN keys | k.id] AS keyIds
|
||||
OPTIONAL MATCH (p:Post) WHERE p.createdByApiKey IN keyIds
|
||||
WITH u, activeCount, revokedCount, lastActivity, keyIds, count(p) AS postsCount
|
||||
OPTIONAL MATCH (c:Comment) WHERE c.createdByApiKey IN keyIds
|
||||
RETURN u {.*} AS user, activeCount, revokedCount, postsCount, count(c) AS commentsCount, lastActivity
|
||||
`,
|
||||
variables: { offset, first },
|
||||
})
|
||||
return result.records.map((r: Neo4jRecord) => ({
|
||||
user: r.get('user') as Record<string, unknown>,
|
||||
activeCount: toNumber(r.get('activeCount') as Integer),
|
||||
revokedCount: toNumber(r.get('revokedCount') as Integer),
|
||||
postsCount: toNumber(r.get('postsCount') as Integer),
|
||||
commentsCount: toNumber(r.get('commentsCount') as Integer),
|
||||
lastActivity: (r.get('lastActivity') as string | null) ?? null,
|
||||
}))
|
||||
},
|
||||
|
||||
apiKeysForUser: async (_parent: unknown, args: ApiKeyArgs, context: Context) => {
|
||||
const result = await context.database.query({
|
||||
query: `
|
||||
MATCH (u:User { id: $userId })-[:HAS_API_KEY]->(k:ApiKey)
|
||||
RETURN k {.*}
|
||||
ORDER BY k.disabled ASC, k.createdAt DESC
|
||||
`,
|
||||
variables: { userId: args.userId },
|
||||
})
|
||||
return result.records.map((r: Neo4jRecord) => normalizeApiKey(getRecord(r, 'k')))
|
||||
},
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
createApiKey: async (_parent: unknown, args: ApiKeyArgs, context: Context) => {
|
||||
if (!context.config.API_KEYS_ENABLED) {
|
||||
throw new Error('API keys are not enabled')
|
||||
}
|
||||
|
||||
if (args.expiresInDays != null && args.expiresInDays < 1) {
|
||||
throw new Error('expiresInDays must be a positive integer')
|
||||
}
|
||||
|
||||
let expiresAt: string | null = null
|
||||
if (args.expiresInDays) {
|
||||
expiresAt = new Date(Date.now() + args.expiresInDays * 86400000).toISOString()
|
||||
}
|
||||
|
||||
const { key, hash, prefix } = generateApiKey()
|
||||
const id = uuid()
|
||||
|
||||
const result = await context.database.write({
|
||||
query: `
|
||||
MATCH (u:User { id: $userId })
|
||||
OPTIONAL MATCH (u)-[:HAS_API_KEY]->(existing:ApiKey)
|
||||
WHERE NOT existing.disabled
|
||||
WITH u, count(existing) AS activeCount
|
||||
WHERE activeCount < toInteger($maxKeys)
|
||||
CREATE (u)-[:HAS_API_KEY]->(k:ApiKey {
|
||||
id: $id,
|
||||
name: $name,
|
||||
keyHash: $keyHash,
|
||||
keyPrefix: $keyPrefix,
|
||||
createdAt: toString(datetime()),
|
||||
disabled: false
|
||||
})
|
||||
${expiresAt ? 'SET k.expiresAt = $expiresAt' : ''}
|
||||
RETURN k {.*}
|
||||
`,
|
||||
variables: {
|
||||
userId: context.user?.id,
|
||||
id,
|
||||
name: args.name,
|
||||
keyHash: hash,
|
||||
keyPrefix: prefix,
|
||||
expiresAt,
|
||||
maxKeys: context.config.API_KEYS_MAX_PER_USER,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.records.length === 0) {
|
||||
throw new Error(
|
||||
`Maximum of ${String(context.config.API_KEYS_MAX_PER_USER)} active API keys reached`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: normalizeApiKey(getRecord(result.records[0], 'k')),
|
||||
secret: key,
|
||||
}
|
||||
},
|
||||
|
||||
updateApiKey: async (_parent: unknown, args: ApiKeyArgs, context: Context) => {
|
||||
const result = await context.database.write({
|
||||
query: `
|
||||
MATCH (u:User { id: $userId })-[:HAS_API_KEY]->(k:ApiKey { id: $keyId })
|
||||
SET k.name = $name
|
||||
RETURN k {.*}
|
||||
`,
|
||||
variables: { userId: context.user?.id, keyId: args.id, name: args.name },
|
||||
})
|
||||
if (result.records.length === 0) {
|
||||
throw new Error('API key not found')
|
||||
}
|
||||
return normalizeApiKey(getRecord(result.records[0], 'k'))
|
||||
},
|
||||
|
||||
revokeApiKey: async (_parent: unknown, args: ApiKeyArgs, context: Context) => {
|
||||
const result = await context.database.write({
|
||||
query: `
|
||||
MATCH (u:User { id: $userId })-[:HAS_API_KEY]->(k:ApiKey { id: $keyId })
|
||||
WHERE NOT k.disabled
|
||||
SET k.disabled = true, k.disabledAt = toString(datetime())
|
||||
RETURN k
|
||||
`,
|
||||
variables: { userId: context.user?.id, keyId: args.id },
|
||||
})
|
||||
return result.records.length > 0
|
||||
},
|
||||
|
||||
adminRevokeApiKey: async (_parent: unknown, args: ApiKeyArgs, context: Context) => {
|
||||
const result = await context.database.write({
|
||||
query: `
|
||||
MATCH (k:ApiKey { id: $keyId })
|
||||
WHERE NOT k.disabled
|
||||
SET k.disabled = true, k.disabledAt = toString(datetime())
|
||||
RETURN k
|
||||
`,
|
||||
variables: { keyId: args.id },
|
||||
})
|
||||
return result.records.length > 0
|
||||
},
|
||||
|
||||
adminRevokeUserApiKeys: async (_parent: unknown, args: ApiKeyArgs, context: Context) => {
|
||||
const result = await context.database.write({
|
||||
query: `
|
||||
MATCH (u:User { id: $userId })-[:HAS_API_KEY]->(k:ApiKey)
|
||||
WHERE NOT k.disabled
|
||||
SET k.disabled = true, k.disabledAt = toString(datetime())
|
||||
RETURN count(k) AS count
|
||||
`,
|
||||
variables: { userId: args.userId },
|
||||
})
|
||||
return toNumber(result.records[0].get('count') as Integer)
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -30,6 +30,7 @@ export default {
|
||||
CREATE (comment:Comment $params)
|
||||
SET comment.createdAt = toString(datetime())
|
||||
SET comment.updatedAt = toString(datetime())
|
||||
SET comment.createdByApiKey = $apiKeyId
|
||||
MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author)
|
||||
WITH post, author, comment
|
||||
MERGE (post)<-[obs:OBSERVES]-(author)
|
||||
@ -39,7 +40,7 @@ export default {
|
||||
obs.updatedAt = toString(datetime())
|
||||
RETURN comment
|
||||
`,
|
||||
{ userId: user.id, postId, params },
|
||||
{ userId: user.id, postId, params, apiKeyId: user.apiKeyId || null },
|
||||
)
|
||||
return createCommentTransactionResponse.records.map(
|
||||
(record) => record.get('comment').properties,
|
||||
|
||||
@ -189,6 +189,7 @@ export default {
|
||||
SET post.sortDate = toString(datetime())
|
||||
SET post.clickedCount = 0
|
||||
SET post.viewedTeaserCount = 0
|
||||
SET post.createdByApiKey = $apiKeyId
|
||||
SET post:${params.postType}
|
||||
WITH post
|
||||
MATCH (author:User {id: $userId})
|
||||
@ -201,7 +202,7 @@ export default {
|
||||
${groupCypher}
|
||||
RETURN post {.*, postType: [l IN labels(post) WHERE NOT l = 'Post'] }
|
||||
`,
|
||||
{ userId: user.id, categoryIds, groupId, params },
|
||||
{ userId: user.id, categoryIds, groupId, params, apiKeyId: user.apiKeyId || null },
|
||||
)
|
||||
const [post] = createPostTransactionResponse.records.map((record) => record.get('post'))
|
||||
if (imageInput) {
|
||||
|
||||
@ -12,6 +12,9 @@ export default makeAugmentedSchema({
|
||||
config: {
|
||||
query: {
|
||||
exclude: [
|
||||
'ApiKey',
|
||||
'ApiKeyWithSecret',
|
||||
'ApiKeyUserSummary',
|
||||
'Badge',
|
||||
'Embed',
|
||||
'EmailNotificationSettings',
|
||||
|
||||
39
backend/src/graphql/types/type/ApiKey.gql
Normal file
39
backend/src/graphql/types/type/ApiKey.gql
Normal file
@ -0,0 +1,39 @@
|
||||
type ApiKey {
|
||||
id: ID!
|
||||
name: String!
|
||||
keyPrefix: String!
|
||||
createdAt: String!
|
||||
lastUsedAt: String
|
||||
expiresAt: String
|
||||
disabled: Boolean!
|
||||
disabledAt: String
|
||||
owner: User @relation(name: "HAS_API_KEY", direction: "IN")
|
||||
}
|
||||
|
||||
type ApiKeyWithSecret {
|
||||
apiKey: ApiKey!
|
||||
secret: String!
|
||||
}
|
||||
|
||||
type ApiKeyUserSummary {
|
||||
user: User!
|
||||
activeCount: Int!
|
||||
revokedCount: Int!
|
||||
postsCount: Int!
|
||||
commentsCount: Int!
|
||||
lastActivity: String
|
||||
}
|
||||
|
||||
type Query {
|
||||
myApiKeys: [ApiKey!]!
|
||||
apiKeyUsers(first: Int, offset: Int): [ApiKeyUserSummary!]!
|
||||
apiKeysForUser(userId: ID!): [ApiKey!]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createApiKey(name: String!, expiresInDays: Int): ApiKeyWithSecret!
|
||||
updateApiKey(id: ID!, name: String!): ApiKey!
|
||||
revokeApiKey(id: ID!): Boolean!
|
||||
adminRevokeApiKey(id: ID!): Boolean!
|
||||
adminRevokeUserApiKeys(userId: ID!): Int!
|
||||
}
|
||||
@ -42,6 +42,200 @@ describe('decode', () => {
|
||||
await expect(decode(context)(authorizationHeader)).resolves.toBeNull()
|
||||
}
|
||||
|
||||
describe('given API key with oak_ prefix', () => {
|
||||
describe('and valid API key in database', () => {
|
||||
beforeEach(async () => {
|
||||
await Factory.build('user', {
|
||||
id: 'api-user',
|
||||
name: 'API User',
|
||||
slug: 'api-user',
|
||||
role: 'user',
|
||||
})
|
||||
// Create API key node with known hash
|
||||
// SHA-256 of 'oak_testkey123' = known hash
|
||||
const crypto = await import('node:crypto')
|
||||
const keyHash = crypto.createHash('sha256').update('oak_testkey123').digest('hex')
|
||||
const session = driver.session()
|
||||
await session.writeTransaction(async (tx) => {
|
||||
await tx.run(
|
||||
`MATCH (u:User { id: $userId })
|
||||
CREATE (u)-[:HAS_API_KEY]->(k:ApiKey {
|
||||
id: 'ak1',
|
||||
name: 'Test Key',
|
||||
keyHash: $keyHash,
|
||||
keyPrefix: 'oak_testke',
|
||||
createdAt: toString(datetime()),
|
||||
disabled: false
|
||||
})`,
|
||||
{ userId: 'api-user', keyHash },
|
||||
)
|
||||
})
|
||||
await session.close()
|
||||
})
|
||||
|
||||
it('returns user object with authMethod apiKey', async () => {
|
||||
await expect(decode(context)('Bearer oak_testkey123')).resolves.toMatchObject({
|
||||
id: 'api-user',
|
||||
name: 'API User',
|
||||
authMethod: 'apiKey',
|
||||
apiKeyId: 'ak1',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates lastUsedAt on the API key', async () => {
|
||||
await decode(context)('Bearer oak_testkey123')
|
||||
// Give fire-and-forget time to complete
|
||||
const { setTimeout: delay } = await import('node:timers/promises')
|
||||
await delay(500)
|
||||
const session = driver.session()
|
||||
const result = await session.readTransaction(async (tx) => {
|
||||
return tx.run(`MATCH (k:ApiKey { id: 'ak1' }) RETURN k.lastUsedAt AS lastUsedAt`)
|
||||
})
|
||||
await session.close()
|
||||
expect(result.records[0].get('lastUsedAt')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('and disabled API key', () => {
|
||||
beforeEach(async () => {
|
||||
await Factory.build('user', {
|
||||
id: 'disabled-key-user',
|
||||
name: 'DK User',
|
||||
slug: 'dk-user',
|
||||
role: 'user',
|
||||
})
|
||||
const crypto = await import('node:crypto')
|
||||
const keyHash = crypto.createHash('sha256').update('oak_disabledkey').digest('hex')
|
||||
const session = driver.session()
|
||||
await session.writeTransaction(async (tx) => {
|
||||
await tx.run(
|
||||
`MATCH (u:User { id: $userId })
|
||||
CREATE (u)-[:HAS_API_KEY]->(k:ApiKey {
|
||||
id: 'ak-disabled',
|
||||
name: 'Disabled Key',
|
||||
keyHash: $keyHash,
|
||||
keyPrefix: 'oak_disabl',
|
||||
createdAt: toString(datetime()),
|
||||
disabled: true
|
||||
})`,
|
||||
{ userId: 'disabled-key-user', keyHash },
|
||||
)
|
||||
})
|
||||
await session.close()
|
||||
authorizationHeader = 'Bearer oak_disabledkey'
|
||||
})
|
||||
|
||||
it('returns null', returnsNull)
|
||||
})
|
||||
|
||||
describe('and expired API key', () => {
|
||||
beforeEach(async () => {
|
||||
await Factory.build('user', {
|
||||
id: 'expired-key-user',
|
||||
name: 'EK User',
|
||||
slug: 'ek-user',
|
||||
role: 'user',
|
||||
})
|
||||
const crypto = await import('node:crypto')
|
||||
const keyHash = crypto.createHash('sha256').update('oak_expiredkey').digest('hex')
|
||||
const session = driver.session()
|
||||
await session.writeTransaction(async (tx) => {
|
||||
await tx.run(
|
||||
`MATCH (u:User { id: $userId })
|
||||
CREATE (u)-[:HAS_API_KEY]->(k:ApiKey {
|
||||
id: 'ak-expired',
|
||||
name: 'Expired Key',
|
||||
keyHash: $keyHash,
|
||||
keyPrefix: 'oak_expire',
|
||||
createdAt: toString(datetime()),
|
||||
expiresAt: '2020-01-01T00:00:00.000Z',
|
||||
disabled: false
|
||||
})`,
|
||||
{ userId: 'expired-key-user', keyHash },
|
||||
)
|
||||
})
|
||||
await session.close()
|
||||
authorizationHeader = 'Bearer oak_expiredkey'
|
||||
})
|
||||
|
||||
it('returns null', returnsNull)
|
||||
})
|
||||
|
||||
describe('and nonexistent API key', () => {
|
||||
beforeEach(() => {
|
||||
authorizationHeader = 'Bearer oak_doesnotexist'
|
||||
})
|
||||
|
||||
it('returns null', returnsNull)
|
||||
})
|
||||
|
||||
describe('and API key belonging to disabled user', () => {
|
||||
beforeEach(async () => {
|
||||
await Factory.build('user', {
|
||||
id: 'disabled-user',
|
||||
name: 'Disabled User',
|
||||
slug: 'disabled-user',
|
||||
role: 'user',
|
||||
disabled: true,
|
||||
})
|
||||
const crypto = await import('node:crypto')
|
||||
const keyHash = crypto.createHash('sha256').update('oak_disableduser').digest('hex')
|
||||
const session = driver.session()
|
||||
await session.writeTransaction(async (tx) => {
|
||||
await tx.run(
|
||||
`MATCH (u:User { id: $userId })
|
||||
CREATE (u)-[:HAS_API_KEY]->(k:ApiKey {
|
||||
id: 'ak-disabled-user',
|
||||
name: 'Key of Disabled User',
|
||||
keyHash: $keyHash,
|
||||
keyPrefix: 'oak_disabl',
|
||||
createdAt: toString(datetime()),
|
||||
disabled: false
|
||||
})`,
|
||||
{ userId: 'disabled-user', keyHash },
|
||||
)
|
||||
})
|
||||
await session.close()
|
||||
authorizationHeader = 'Bearer oak_disableduser'
|
||||
})
|
||||
|
||||
it('returns null', returnsNull)
|
||||
})
|
||||
|
||||
describe('and API key belonging to deleted user', () => {
|
||||
beforeEach(async () => {
|
||||
await Factory.build('user', {
|
||||
id: 'deleted-user',
|
||||
name: 'Deleted User',
|
||||
slug: 'deleted-user',
|
||||
role: 'user',
|
||||
deleted: true,
|
||||
})
|
||||
const crypto = await import('node:crypto')
|
||||
const keyHash = crypto.createHash('sha256').update('oak_deleteduser').digest('hex')
|
||||
const session = driver.session()
|
||||
await session.writeTransaction(async (tx) => {
|
||||
await tx.run(
|
||||
`MATCH (u:User { id: $userId })
|
||||
CREATE (u)-[:HAS_API_KEY]->(k:ApiKey {
|
||||
id: 'ak-deleted-user',
|
||||
name: 'Key of Deleted User',
|
||||
keyHash: $keyHash,
|
||||
keyPrefix: 'oak_delete',
|
||||
createdAt: toString(datetime()),
|
||||
disabled: false
|
||||
})`,
|
||||
{ userId: 'deleted-user', keyHash },
|
||||
)
|
||||
})
|
||||
await session.close()
|
||||
authorizationHeader = 'Bearer oak_deleteduser'
|
||||
})
|
||||
|
||||
it('returns null', returnsNull)
|
||||
})
|
||||
})
|
||||
|
||||
describe('given `null` as JWT Bearer token', () => {
|
||||
beforeEach(() => {
|
||||
authorizationHeader = null
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import { verify } from 'jsonwebtoken'
|
||||
|
||||
import type CONFIG from '@src/config'
|
||||
@ -12,44 +14,106 @@ export interface DecodedUser {
|
||||
name: string
|
||||
role: string
|
||||
disabled: boolean
|
||||
authMethod?: 'jwt' | 'apiKey'
|
||||
apiKeyId?: string
|
||||
}
|
||||
|
||||
const jwt = { verify }
|
||||
|
||||
const decodeJwt = async (
|
||||
context: { config: Pick<typeof CONFIG, 'JWT_SECRET'>; driver: Driver },
|
||||
token: string,
|
||||
): Promise<DecodedUser | null> => {
|
||||
let id: null | string = null
|
||||
try {
|
||||
const decoded = jwt.verify(token, context.config.JWT_SECRET) as JwtPayload
|
||||
id = decoded.sub ?? null
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const session = context.driver.session()
|
||||
|
||||
const writeTxResultPromise = session.writeTransaction<DecodedUser[]>(async (transaction) => {
|
||||
const updateUserLastActiveTransactionResponse = await transaction.run(
|
||||
`
|
||||
MATCH (user:User {id: $id, deleted: false, disabled: false })
|
||||
SET user.lastActiveAt = toString(datetime())
|
||||
RETURN user {.id, .slug, .name, .role, .disabled, .actorId}
|
||||
LIMIT 1
|
||||
`,
|
||||
{ id },
|
||||
)
|
||||
return updateUserLastActiveTransactionResponse.records.map((record) => record.get('user'))
|
||||
})
|
||||
try {
|
||||
const [currentUser] = await writeTxResultPromise
|
||||
if (!currentUser) return null
|
||||
return {
|
||||
...currentUser,
|
||||
authMethod: 'jwt' as const,
|
||||
}
|
||||
} finally {
|
||||
await session.close()
|
||||
}
|
||||
}
|
||||
|
||||
const decodeApiKey = async (driver: Driver, key: string): Promise<DecodedUser | null> => {
|
||||
const keyHash = createHash('sha256').update(key).digest('hex')
|
||||
const session = driver.session()
|
||||
try {
|
||||
const result = await session.readTransaction(async (transaction) => {
|
||||
return transaction.run(
|
||||
`
|
||||
MATCH (user:User)-[:HAS_API_KEY]->(k:ApiKey { keyHash: $keyHash })
|
||||
WHERE k.disabled = false
|
||||
AND (k.expiresAt IS NULL OR datetime(k.expiresAt) > datetime())
|
||||
AND user.deleted = false
|
||||
AND user.disabled = false
|
||||
RETURN user {.id, .slug, .name, .role, .disabled, .actorId} AS user, k.id AS keyId
|
||||
LIMIT 1
|
||||
`,
|
||||
{ keyHash },
|
||||
)
|
||||
})
|
||||
if (result.records.length === 0) return null
|
||||
|
||||
const record = result.records[0]
|
||||
const user = record.get('user') as DecodedUser
|
||||
const keyId = record.get('keyId') as string
|
||||
|
||||
// Update lastUsedAt asynchronously (non-blocking, separate session)
|
||||
const updateSession = driver.session()
|
||||
void updateSession
|
||||
.writeTransaction(async (transaction) => {
|
||||
await transaction.run(
|
||||
`MATCH (k:ApiKey { id: $keyId }) SET k.lastUsedAt = toString(datetime())`,
|
||||
{ keyId },
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(async () => updateSession.close())
|
||||
|
||||
return {
|
||||
...user,
|
||||
authMethod: 'apiKey' as const,
|
||||
apiKeyId: keyId,
|
||||
}
|
||||
} finally {
|
||||
await session.close()
|
||||
}
|
||||
}
|
||||
|
||||
export const decode =
|
||||
(context: { config: Pick<typeof CONFIG, 'JWT_SECRET'>; driver: Driver }) =>
|
||||
async (authorizationHeader: string | undefined | null) => {
|
||||
if (!authorizationHeader) return null
|
||||
const token = authorizationHeader.replace('Bearer ', '')
|
||||
let id: null | string = null
|
||||
try {
|
||||
const decoded = jwt.verify(token, context.config.JWT_SECRET) as JwtPayload
|
||||
id = decoded.sub ?? null
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const session = context.driver.session()
|
||||
|
||||
const writeTxResultPromise = session.writeTransaction<DecodedUser[]>(async (transaction) => {
|
||||
const updateUserLastActiveTransactionResponse = await transaction.run(
|
||||
`
|
||||
MATCH (user:User {id: $id, deleted: false, disabled: false })
|
||||
SET user.lastActiveAt = toString(datetime())
|
||||
RETURN user {.id, .slug, .name, .role, .disabled, .actorId}
|
||||
LIMIT 1
|
||||
`,
|
||||
{ id },
|
||||
)
|
||||
return updateUserLastActiveTransactionResponse.records.map((record) => record.get('user'))
|
||||
})
|
||||
try {
|
||||
const [currentUser] = await writeTxResultPromise
|
||||
if (!currentUser) return null
|
||||
return {
|
||||
token,
|
||||
...currentUser,
|
||||
}
|
||||
} finally {
|
||||
await session.close()
|
||||
// Route by token prefix: oak_ = API key, otherwise JWT
|
||||
if (token.startsWith('oak_')) {
|
||||
return decodeApiKey(context.driver, token)
|
||||
}
|
||||
|
||||
return decodeJwt(context, token)
|
||||
}
|
||||
|
||||
@ -34,6 +34,14 @@ const isAdmin = rule()(async (_parent, _args, { user }: Context, _info) => {
|
||||
return !!(user?.role === 'admin')
|
||||
})
|
||||
|
||||
const apiKeysEnabled = rule({ cache: 'contextual' })(async (
|
||||
_parent,
|
||||
_args,
|
||||
{ config }: Context,
|
||||
) => {
|
||||
return config.API_KEYS_ENABLED
|
||||
})
|
||||
|
||||
const onlyYourself = rule({
|
||||
cache: 'no_cache',
|
||||
})(async (_parent, args, context: Context, _info) => {
|
||||
@ -462,6 +470,11 @@ export default shield(
|
||||
|
||||
// Invite Code
|
||||
validateInviteCode: allow,
|
||||
|
||||
// API Keys
|
||||
myApiKeys: and(isAuthenticated, apiKeysEnabled),
|
||||
apiKeyUsers: isAdmin,
|
||||
apiKeysForUser: isAdmin,
|
||||
},
|
||||
Mutation: {
|
||||
'*': deny,
|
||||
@ -521,6 +534,13 @@ export default shield(
|
||||
invalidateInviteCode: isAuthenticated,
|
||||
redeemInviteCode: isAuthenticated,
|
||||
|
||||
// API Keys
|
||||
createApiKey: and(isAuthenticated, apiKeysEnabled),
|
||||
updateApiKey: isAuthenticated,
|
||||
revokeApiKey: isAuthenticated,
|
||||
adminRevokeApiKey: isAdmin,
|
||||
adminRevokeUserApiKeys: isAdmin,
|
||||
|
||||
switchUserRole: isAdmin,
|
||||
markTeaserAsViewed: allow,
|
||||
saveCategorySettings: isAuthenticated,
|
||||
|
||||
@ -59,6 +59,8 @@ export const TEST_CONFIG = {
|
||||
CATEGORIES_ACTIVE: false,
|
||||
MAX_PINNED_POSTS: 1,
|
||||
MAX_GROUP_PINNED_POSTS: 1,
|
||||
API_KEYS_ENABLED: false,
|
||||
API_KEYS_MAX_PER_USER: 5,
|
||||
|
||||
LANGUAGE_DEFAULT: 'en',
|
||||
LOG_LEVEL: 'DEBUG',
|
||||
|
||||
29
cypress/e2e/settings/ApiKey.feature
Normal file
29
cypress/e2e/settings/ApiKey.feature
Normal file
@ -0,0 +1,29 @@
|
||||
Feature: API Key Authentication
|
||||
As a User
|
||||
I'd like to create and use API keys
|
||||
So I can access the API without my password
|
||||
|
||||
Background:
|
||||
Given the following "users" are in the database:
|
||||
| email | password | id | name | slug | role | termsAndConditionsAgreedVersion |
|
||||
| peterpan@example.org | 123 | id-of-peter-pan | Peter Pan | peter-pan | user | 0.0.4 |
|
||||
|
||||
Scenario: Create an API key and use it to query the API
|
||||
Given I am logged in as "peter-pan"
|
||||
When I navigate to page "/settings/api-keys"
|
||||
Then I am on page "/settings/api-keys"
|
||||
When I create an API key with name "E2E Test Key"
|
||||
Then I see a toaster with status "success"
|
||||
And I see the API key secret
|
||||
When I use the API key to query currentUser
|
||||
Then the API returns my user name "Peter Pan"
|
||||
|
||||
Scenario: Revoked API key is rejected
|
||||
Given I am logged in as "peter-pan"
|
||||
When I navigate to page "/settings/api-keys"
|
||||
And I create an API key with name "To Revoke"
|
||||
And I revoke the first API key
|
||||
And I confirm the action in the modal
|
||||
Then I see a toaster with status "success"
|
||||
When I use the revoked API key to query currentUser
|
||||
Then the API returns an authentication error
|
||||
@ -0,0 +1,6 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep('I confirm the action in the modal', () => {
|
||||
cy.get('[data-test="confirm-modal"]').should('be.visible')
|
||||
cy.get('[data-test="confirm-button"]').click()
|
||||
})
|
||||
@ -0,0 +1,6 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep('I create an API key with name {string}', (name) => {
|
||||
cy.get('#api-key-name').clear().type(name)
|
||||
cy.get('#api-key-name').closest('form').submit()
|
||||
})
|
||||
@ -0,0 +1,9 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep('I revoke the first API key', () => {
|
||||
cy.get('[data-test="revoke-api-key"]')
|
||||
.first()
|
||||
.scrollIntoView()
|
||||
.should('be.visible')
|
||||
.click()
|
||||
})
|
||||
@ -0,0 +1,9 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep('I see the API key secret', () => {
|
||||
cy.get('.secret-code').should('be.visible').invoke('text').then((secret) => {
|
||||
const trimmed = secret.trim()
|
||||
expect(trimmed).to.match(/^oak_/)
|
||||
cy.task('pushValue', { name: 'apiKeySecret', value: trimmed })
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,14 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
|
||||
defineStep('I use the API key to query currentUser', () => {
|
||||
cy.task('getValue', 'apiKeySecret').then((secret) => {
|
||||
const client = new GraphQLClient(Cypress.env('GRAPHQL_URI'), {
|
||||
headers: { authorization: `Bearer ${secret}` },
|
||||
})
|
||||
const query = `query { currentUser { id name } }`
|
||||
cy.wrap(client.request(query)).then((response) => {
|
||||
cy.task('pushValue', { name: 'apiKeyResponse', value: response })
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,16 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep('I use the revoked API key to query currentUser', () => {
|
||||
cy.task('getValue', 'apiKeySecret').then((secret) => {
|
||||
expect(secret).to.be.a('string').and.match(/^oak_/)
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: Cypress.env('GRAPHQL_URI'),
|
||||
headers: { authorization: `Bearer ${secret}`, 'content-type': 'application/json' },
|
||||
body: { query: '{ currentUser { id name } }' },
|
||||
failOnStatusCode: false,
|
||||
}).then((response) => {
|
||||
cy.task('pushValue', { name: 'revokedApiKeyResponse', value: response.body })
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,11 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep('the API returns an authentication error', () => {
|
||||
cy.task('getValue', 'revokedApiKeyResponse').then((response) => {
|
||||
// currentUser should be null (unauthenticated) or return an error
|
||||
const hasError =
|
||||
(response.errors && response.errors.length > 0) ||
|
||||
(response.data && response.data.currentUser === null)
|
||||
expect(hasError).to.be.true
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,7 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep('the API returns my user name {string}', (expectedName) => {
|
||||
cy.task('getValue', 'apiKeyResponse').then((response) => {
|
||||
expect(response.currentUser.name).to.equal(expectedName)
|
||||
})
|
||||
})
|
||||
@ -7,6 +7,8 @@ services:
|
||||
target: test
|
||||
environment:
|
||||
- NODE_ENV=test
|
||||
- API_KEYS_ENABLED=true
|
||||
- API_KEYS_MAX_PER_USER=5
|
||||
volumes:
|
||||
- ./coverage:/app/coverage
|
||||
|
||||
|
||||
@ -12,4 +12,6 @@ NETWORK_NAME="Ocelot.social"
|
||||
ASK_FOR_REAL_NAME=false
|
||||
|
||||
REQUIRE_LOCATION=false
|
||||
MAX_GROUP_PINNED_POSTS=1
|
||||
MAX_GROUP_PINNED_POSTS=1
|
||||
API_KEYS_ENABLED=false
|
||||
API_KEYS_MAX_PER_USER=5
|
||||
@ -61,6 +61,8 @@ const options = {
|
||||
MAX_GROUP_PINNED_POSTS: Number.isNaN(Number(process.env.MAX_GROUP_PINNED_POSTS))
|
||||
? 1
|
||||
: Number(process.env.MAX_GROUP_PINNED_POSTS),
|
||||
API_KEYS_ENABLED: process.env.API_KEYS_ENABLED === 'true' || false,
|
||||
API_KEYS_MAX_PER_USER: Number(process.env.API_KEYS_MAX_PER_USER) || 5,
|
||||
}
|
||||
|
||||
const language = {
|
||||
|
||||
59
webapp/graphql/admin/ApiKeys.js
Normal file
59
webapp/graphql/admin/ApiKeys.js
Normal file
@ -0,0 +1,59 @@
|
||||
import gql from 'graphql-tag'
|
||||
import { imageUrls } from '../fragments/imageUrls'
|
||||
|
||||
export const apiKeyUsersQuery = () => {
|
||||
return gql`
|
||||
${imageUrls}
|
||||
|
||||
query ($first: Int, $offset: Int) {
|
||||
apiKeyUsers(first: $first, offset: $offset) {
|
||||
user {
|
||||
id
|
||||
name
|
||||
slug
|
||||
avatar {
|
||||
...imageUrls
|
||||
}
|
||||
}
|
||||
activeCount
|
||||
revokedCount
|
||||
postsCount
|
||||
commentsCount
|
||||
lastActivity
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export const apiKeysForUserQuery = () => {
|
||||
return gql`
|
||||
query ($userId: ID!) {
|
||||
apiKeysForUser(userId: $userId) {
|
||||
id
|
||||
name
|
||||
keyPrefix
|
||||
createdAt
|
||||
lastUsedAt
|
||||
expiresAt
|
||||
disabled
|
||||
disabledAt
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export const adminRevokeApiKeyMutation = () => {
|
||||
return gql`
|
||||
mutation ($id: ID!) {
|
||||
adminRevokeApiKey(id: $id)
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export const adminRevokeUserApiKeysMutation = () => {
|
||||
return gql`
|
||||
mutation ($userId: ID!) {
|
||||
adminRevokeUserApiKeys(userId: $userId)
|
||||
}
|
||||
`
|
||||
}
|
||||
55
webapp/graphql/settings/ApiKeys.js
Normal file
55
webapp/graphql/settings/ApiKeys.js
Normal file
@ -0,0 +1,55 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const myApiKeysQuery = () => {
|
||||
return gql`
|
||||
query {
|
||||
myApiKeys {
|
||||
id
|
||||
name
|
||||
keyPrefix
|
||||
createdAt
|
||||
lastUsedAt
|
||||
expiresAt
|
||||
disabled
|
||||
disabledAt
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export const createApiKeyMutation = () => {
|
||||
return gql`
|
||||
mutation ($name: String!, $expiresInDays: Int) {
|
||||
createApiKey(name: $name, expiresInDays: $expiresInDays) {
|
||||
apiKey {
|
||||
id
|
||||
name
|
||||
keyPrefix
|
||||
createdAt
|
||||
expiresAt
|
||||
disabled
|
||||
}
|
||||
secret
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export const updateApiKeyMutation = () => {
|
||||
return gql`
|
||||
mutation ($id: ID!, $name: String!) {
|
||||
updateApiKey(id: $id, name: $name) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export const revokeApiKeyMutation = () => {
|
||||
return gql`
|
||||
mutation ($id: ID!) {
|
||||
revokeApiKey(id: $id)
|
||||
}
|
||||
`
|
||||
}
|
||||
@ -14,6 +14,48 @@
|
||||
"search": "Suchen"
|
||||
},
|
||||
"admin": {
|
||||
"api-keys": {
|
||||
"detail": {
|
||||
"active": "Aktive Keys ({count})",
|
||||
"revoked": "Widerrufene Keys ({count})"
|
||||
},
|
||||
"empty": "Keine Nutzer mit API Keys gefunden.",
|
||||
"name": "API Keys",
|
||||
"never": "nie",
|
||||
"order": {
|
||||
"active-keys": "Aktive Keys",
|
||||
"comments": "Anzahl Kommentare",
|
||||
"last-used": "Letzte Aktivität",
|
||||
"posts": "Anzahl Beiträge"
|
||||
},
|
||||
"revoke": {
|
||||
"confirm": "Widerrufen",
|
||||
"message": "Möchtest du den API Key \"{name}\" von {user} wirklich widerrufen?",
|
||||
"success": "API Key wurde widerrufen",
|
||||
"title": "API Key widerrufen"
|
||||
},
|
||||
"revoke-all": "Alle Keys dieses Nutzers widerrufen",
|
||||
"revoke-all-confirm": "Alle widerrufen",
|
||||
"revoke-all-message": "Möchtest du wirklich ALLE API Keys von {user} widerrufen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"revoke-all-short": "Alle widerrufen",
|
||||
"revoke-all-success": "{count} API Keys von {user} wurden widerrufen",
|
||||
"revoke-all-title": "Alle API Keys widerrufen",
|
||||
"revoke-key": "Key widerrufen",
|
||||
"revoked-at": "Widerrufen am",
|
||||
"show-keys": "Keys anzeigen",
|
||||
"sort-by": "Sortieren nach",
|
||||
"table": {
|
||||
"actions": "Aktionen",
|
||||
"active": "Aktiv",
|
||||
"comments": "Kommentare",
|
||||
"last-activity": "Letzte Aktivität",
|
||||
"name": "Name",
|
||||
"posts": "Beiträge",
|
||||
"prefix": "Key",
|
||||
"revoked-count": "Widerrufen",
|
||||
"user": "Nutzer"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"description": "Stelle die verfügbaren Auszeichnungen für diesen Nutzer ein.",
|
||||
"noBadges": "Keine Auszeichnungen vorhanden.",
|
||||
@ -1064,6 +1106,51 @@
|
||||
"title": "Suchergebnisse"
|
||||
},
|
||||
"settings": {
|
||||
"api-keys": {
|
||||
"create": {
|
||||
"expiry-days": "{days} Tage",
|
||||
"expiry-label": "Gültigkeit",
|
||||
"expiry-never": "Kein Ablaufdatum",
|
||||
"limit-reached": "Du hast das Maximum von {max} aktiven API Keys erreicht. Widerrufe einen bestehenden Key, um einen neuen zu erstellen.",
|
||||
"name-label": "Name",
|
||||
"submit": "Key erstellen",
|
||||
"success": "API Key wurde erstellt",
|
||||
"title": "Neuen API Key erstellen"
|
||||
},
|
||||
"list": {
|
||||
"actions": "Aktionen",
|
||||
"created-at": "Erstellt am",
|
||||
"empty": "Du hast noch keine API Keys.",
|
||||
"expired": "abgelaufen",
|
||||
"expires": "Läuft ab",
|
||||
"last-used": "Letzte Nutzung",
|
||||
"name": "Name",
|
||||
"never": "nie",
|
||||
"never-expires": "nie",
|
||||
"prefix": "Key",
|
||||
"revoke": "Key widerrufen",
|
||||
"revoked": "widerrufen",
|
||||
"title": "Meine API Keys"
|
||||
},
|
||||
"name": "API Keys",
|
||||
"revoke": {
|
||||
"confirm": "Widerrufen",
|
||||
"message": "Möchtest du den API Key \"{name}\" wirklich widerrufen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"success": "API Key \"{name}\" wurde widerrufen",
|
||||
"title": "API Key widerrufen"
|
||||
},
|
||||
"revoked-list": {
|
||||
"revoked-at": "Widerrufen am",
|
||||
"title": "Widerrufene Keys ({count})"
|
||||
},
|
||||
"secret": {
|
||||
"copied": "API Key in die Zwischenablage kopiert",
|
||||
"copy": "Kopieren",
|
||||
"copy-failed": "Kopieren fehlgeschlagen",
|
||||
"title": "Dein neuer API Key (nur einmal sichtbar!)",
|
||||
"warning": "Speichere diesen Key jetzt. Du kannst ihn nach dem Schließen dieser Seite nicht mehr einsehen."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"click-to-select": "Klicke auf einen freien Platz, um eine Badge hinzufügen.",
|
||||
"click-to-use": "Klicke auf eine Badge, um sie zu platzieren.",
|
||||
|
||||
@ -14,6 +14,48 @@
|
||||
"search": "Search"
|
||||
},
|
||||
"admin": {
|
||||
"api-keys": {
|
||||
"detail": {
|
||||
"active": "Active keys ({count})",
|
||||
"revoked": "Revoked keys ({count})"
|
||||
},
|
||||
"empty": "No users with API keys found.",
|
||||
"name": "API Keys",
|
||||
"never": "never",
|
||||
"order": {
|
||||
"active-keys": "Active keys",
|
||||
"comments": "Comments count",
|
||||
"last-used": "Last activity",
|
||||
"posts": "Posts count"
|
||||
},
|
||||
"revoke": {
|
||||
"confirm": "Revoke",
|
||||
"message": "Are you sure you want to revoke the API key \"{name}\" of {user}?",
|
||||
"success": "API key revoked successfully",
|
||||
"title": "Revoke API Key"
|
||||
},
|
||||
"revoke-all": "Revoke all keys of this user",
|
||||
"revoke-all-confirm": "Revoke all",
|
||||
"revoke-all-message": "Are you sure you want to revoke ALL API keys of {user}? This action cannot be undone.",
|
||||
"revoke-all-short": "Revoke all",
|
||||
"revoke-all-success": "{count} API keys of {user} have been revoked",
|
||||
"revoke-all-title": "Revoke all API Keys",
|
||||
"revoke-key": "Revoke key",
|
||||
"revoked-at": "Revoked at",
|
||||
"show-keys": "Show keys",
|
||||
"sort-by": "Sort by",
|
||||
"table": {
|
||||
"actions": "Actions",
|
||||
"active": "Active",
|
||||
"comments": "Comments",
|
||||
"last-activity": "Last activity",
|
||||
"name": "Name",
|
||||
"posts": "Posts",
|
||||
"prefix": "Key",
|
||||
"revoked-count": "Revoked",
|
||||
"user": "User"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"description": "Configure the available badges for this user",
|
||||
"noBadges": "There are no badges available",
|
||||
@ -1064,6 +1106,51 @@
|
||||
"title": "Search Results"
|
||||
},
|
||||
"settings": {
|
||||
"api-keys": {
|
||||
"create": {
|
||||
"expiry-days": "{days} days",
|
||||
"expiry-label": "Expiry",
|
||||
"expiry-never": "No expiry",
|
||||
"limit-reached": "You have reached the maximum of {max} active API keys. Revoke an existing key to create a new one.",
|
||||
"name-label": "Name",
|
||||
"submit": "Create key",
|
||||
"success": "API key created successfully",
|
||||
"title": "Create new API Key"
|
||||
},
|
||||
"list": {
|
||||
"actions": "Actions",
|
||||
"created-at": "Created at",
|
||||
"empty": "You have no API keys yet.",
|
||||
"expired": "expired",
|
||||
"expires": "Expires",
|
||||
"last-used": "Last used",
|
||||
"name": "Name",
|
||||
"never": "never",
|
||||
"never-expires": "never",
|
||||
"prefix": "Key",
|
||||
"revoke": "Revoke key",
|
||||
"revoked": "revoked",
|
||||
"title": "My API Keys"
|
||||
},
|
||||
"name": "API Keys",
|
||||
"revoke": {
|
||||
"confirm": "Revoke",
|
||||
"message": "Are you sure you want to revoke the API key \"{name}\"? This action cannot be undone.",
|
||||
"success": "API key \"{name}\" has been revoked",
|
||||
"title": "Revoke API Key"
|
||||
},
|
||||
"revoked-list": {
|
||||
"revoked-at": "Revoked at",
|
||||
"title": "Revoked keys ({count})"
|
||||
},
|
||||
"secret": {
|
||||
"copied": "API key copied to clipboard",
|
||||
"copy": "Copy",
|
||||
"copy-failed": "Failed to copy to clipboard",
|
||||
"title": "Your new API Key (visible only once!)",
|
||||
"warning": "Save this key now. You will not be able to see it again after closing this page."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"click-to-select": "Click on an empty space to add a badge.",
|
||||
"click-to-use": "Click on a badge to use it in the selected slot.",
|
||||
|
||||
@ -14,6 +14,48 @@
|
||||
"search": "Buscar"
|
||||
},
|
||||
"admin": {
|
||||
"api-keys": {
|
||||
"detail": {
|
||||
"active": "Keys activas ({count})",
|
||||
"revoked": "Keys revocadas ({count})"
|
||||
},
|
||||
"empty": "No se encontraron usuarios con API Keys.",
|
||||
"name": "API Keys",
|
||||
"never": "nunca",
|
||||
"order": {
|
||||
"active-keys": "Keys activas",
|
||||
"comments": "Número de comentarios",
|
||||
"last-used": "Última actividad",
|
||||
"posts": "Número de publicaciones"
|
||||
},
|
||||
"revoke": {
|
||||
"confirm": "Revocar",
|
||||
"message": "¿Estás seguro de que quieres revocar la API Key \"{name}\" de {user}?",
|
||||
"success": "API Key revocada con éxito",
|
||||
"title": "Revocar API Key"
|
||||
},
|
||||
"revoke-all": "Revocar todas las keys de este usuario",
|
||||
"revoke-all-confirm": "Revocar todas",
|
||||
"revoke-all-message": "¿Estás seguro de que quieres revocar TODAS las API Keys de {user}? Esta acción no se puede deshacer.",
|
||||
"revoke-all-short": "Revocar todas",
|
||||
"revoke-all-success": "{count} API Keys de {user} han sido revocadas",
|
||||
"revoke-all-title": "Revocar todas las API Keys",
|
||||
"revoke-key": "Revocar key",
|
||||
"revoked-at": "Revocada el",
|
||||
"show-keys": "Mostrar keys",
|
||||
"sort-by": "Ordenar por",
|
||||
"table": {
|
||||
"actions": "Acciones",
|
||||
"active": "Activas",
|
||||
"comments": "Comentarios",
|
||||
"last-activity": "Última actividad",
|
||||
"name": "Nombre",
|
||||
"posts": "Publicaciones",
|
||||
"prefix": "Key",
|
||||
"revoked-count": "Revocadas",
|
||||
"user": "Usuario"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"description": "Configurar las insignias disponibles para este usuario",
|
||||
"noBadges": "No hay insignias disponibles",
|
||||
@ -1064,6 +1106,51 @@
|
||||
"title": "Resultados de búsqueda"
|
||||
},
|
||||
"settings": {
|
||||
"api-keys": {
|
||||
"create": {
|
||||
"expiry-days": "{days} días",
|
||||
"expiry-label": "Validez",
|
||||
"expiry-never": "Sin fecha de expiración",
|
||||
"limit-reached": "Has alcanzado el máximo de {max} API Keys activas. Revoca una key existente para crear una nueva.",
|
||||
"name-label": "Nombre",
|
||||
"submit": "Crear key",
|
||||
"success": "API Key creada con éxito",
|
||||
"title": "Crear nueva API Key"
|
||||
},
|
||||
"list": {
|
||||
"actions": "Acciones",
|
||||
"created-at": "Creada el",
|
||||
"empty": "Aún no tienes API Keys.",
|
||||
"expired": "expirada",
|
||||
"expires": "Expira",
|
||||
"last-used": "Último uso",
|
||||
"name": "Nombre",
|
||||
"never": "nunca",
|
||||
"never-expires": "nunca",
|
||||
"prefix": "Key",
|
||||
"revoke": "Revocar key",
|
||||
"revoked": "revocada",
|
||||
"title": "Mis API Keys"
|
||||
},
|
||||
"name": "API Keys",
|
||||
"revoke": {
|
||||
"confirm": "Revocar",
|
||||
"message": "¿Estás seguro de que quieres revocar la API Key \"{name}\"? Esta acción no se puede deshacer.",
|
||||
"success": "API Key \"{name}\" ha sido revocada",
|
||||
"title": "Revocar API Key"
|
||||
},
|
||||
"revoked-list": {
|
||||
"revoked-at": "Revocada el",
|
||||
"title": "Keys revocadas ({count})"
|
||||
},
|
||||
"secret": {
|
||||
"copied": "API Key copiada al portapapeles",
|
||||
"copy": "Copiar",
|
||||
"copy-failed": "Error al copiar al portapapeles",
|
||||
"title": "Tu nueva API Key (¡visible solo una vez!)",
|
||||
"warning": "Guarda esta key ahora. No podrás verla de nuevo después de cerrar esta página."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"click-to-select": "Haz clic en un espacio vacío para añadir una insignia.",
|
||||
"click-to-use": "Haz clic en una insignia para colocarla en el espacio seleccionado.",
|
||||
|
||||
@ -14,6 +14,48 @@
|
||||
"search": "Rechercher"
|
||||
},
|
||||
"admin": {
|
||||
"api-keys": {
|
||||
"detail": {
|
||||
"active": "Keys actives ({count})",
|
||||
"revoked": "Keys révoquées ({count})"
|
||||
},
|
||||
"empty": "Aucun utilisateur avec des API Keys trouvé.",
|
||||
"name": "API Keys",
|
||||
"never": "jamais",
|
||||
"order": {
|
||||
"active-keys": "Keys actives",
|
||||
"comments": "Nombre de commentaires",
|
||||
"last-used": "Dernière activité",
|
||||
"posts": "Nombre de publications"
|
||||
},
|
||||
"revoke": {
|
||||
"confirm": "Révoquer",
|
||||
"message": "Es-tu sûr de vouloir révoquer l'API Key \"{name}\" de {user} ?",
|
||||
"success": "API Key révoquée avec succès",
|
||||
"title": "Révoquer l'API Key"
|
||||
},
|
||||
"revoke-all": "Révoquer toutes les keys de cet utilisateur",
|
||||
"revoke-all-confirm": "Tout révoquer",
|
||||
"revoke-all-message": "Es-tu sûr de vouloir révoquer TOUTES les API Keys de {user} ? Cette action est irréversible.",
|
||||
"revoke-all-short": "Tout révoquer",
|
||||
"revoke-all-success": "{count} API Keys de {user} ont été révoquées",
|
||||
"revoke-all-title": "Révoquer toutes les API Keys",
|
||||
"revoke-key": "Révoquer la key",
|
||||
"revoked-at": "Révoquée le",
|
||||
"show-keys": "Afficher les keys",
|
||||
"sort-by": "Trier par",
|
||||
"table": {
|
||||
"actions": "Actions",
|
||||
"active": "Actives",
|
||||
"comments": "Commentaires",
|
||||
"last-activity": "Dernière activité",
|
||||
"name": "Nom",
|
||||
"posts": "Publications",
|
||||
"prefix": "Key",
|
||||
"revoked-count": "Révoquées",
|
||||
"user": "Utilisateur"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"description": "Configurer les badges disponibles pour cet utilisateur",
|
||||
"noBadges": "Aucun badge disponible",
|
||||
@ -1064,6 +1106,51 @@
|
||||
"title": "Résultats de recherche"
|
||||
},
|
||||
"settings": {
|
||||
"api-keys": {
|
||||
"create": {
|
||||
"expiry-days": "{days} jours",
|
||||
"expiry-label": "Validité",
|
||||
"expiry-never": "Sans date d'expiration",
|
||||
"limit-reached": "Tu as atteint le maximum de {max} API Keys actives. Révoque une key existante pour en créer une nouvelle.",
|
||||
"name-label": "Nom",
|
||||
"submit": "Créer la key",
|
||||
"success": "API Key créée avec succès",
|
||||
"title": "Créer un nouveau API Key"
|
||||
},
|
||||
"list": {
|
||||
"actions": "Actions",
|
||||
"created-at": "Créée le",
|
||||
"empty": "Tu n'as pas encore d'API Keys.",
|
||||
"expired": "expirée",
|
||||
"expires": "Expire",
|
||||
"last-used": "Dernière utilisation",
|
||||
"name": "Nom",
|
||||
"never": "jamais",
|
||||
"never-expires": "jamais",
|
||||
"prefix": "Key",
|
||||
"revoke": "Révoquer la key",
|
||||
"revoked": "révoquée",
|
||||
"title": "Mes API Keys"
|
||||
},
|
||||
"name": "API Keys",
|
||||
"revoke": {
|
||||
"confirm": "Révoquer",
|
||||
"message": "Es-tu sûr de vouloir révoquer l'API Key \"{name}\" ? Cette action est irréversible.",
|
||||
"success": "L'API Key \"{name}\" a été révoquée",
|
||||
"title": "Révoquer l'API Key"
|
||||
},
|
||||
"revoked-list": {
|
||||
"revoked-at": "Révoquée le",
|
||||
"title": "Keys révoquées ({count})"
|
||||
},
|
||||
"secret": {
|
||||
"copied": "API Key copiée dans le presse-papiers",
|
||||
"copy": "Copier",
|
||||
"copy-failed": "Échec de la copie dans le presse-papiers",
|
||||
"title": "Ton nouveau API Key (visible une seule fois !)",
|
||||
"warning": "Enregistre cette key maintenant. Tu ne pourras plus la voir après avoir fermé cette page."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"click-to-select": "Cliquez sur un emplacement vide pour ajouter un badge.",
|
||||
"click-to-use": "Cliquez sur un badge pour le placer dans l'emplacement sélectionné.",
|
||||
|
||||
@ -14,6 +14,48 @@
|
||||
"search": "Cerca"
|
||||
},
|
||||
"admin": {
|
||||
"api-keys": {
|
||||
"detail": {
|
||||
"active": "Keys attive ({count})",
|
||||
"revoked": "Keys revocate ({count})"
|
||||
},
|
||||
"empty": "Nessun utente con API Keys trovato.",
|
||||
"name": "API Keys",
|
||||
"never": "mai",
|
||||
"order": {
|
||||
"active-keys": "Keys attive",
|
||||
"comments": "Numero di commenti",
|
||||
"last-used": "Ultima attività",
|
||||
"posts": "Numero di post"
|
||||
},
|
||||
"revoke": {
|
||||
"confirm": "Revoca",
|
||||
"message": "Sei sicuro di voler revocare l'API Key \"{name}\" di {user}?",
|
||||
"success": "API Key revocata con successo",
|
||||
"title": "Revoca API Key"
|
||||
},
|
||||
"revoke-all": "Revoca tutte le keys di questo utente",
|
||||
"revoke-all-confirm": "Revoca tutte",
|
||||
"revoke-all-message": "Sei sicuro di voler revocare TUTTE le API Keys di {user}? Questa azione non può essere annullata.",
|
||||
"revoke-all-short": "Revoca tutte",
|
||||
"revoke-all-success": "{count} API Keys di {user} sono state revocate",
|
||||
"revoke-all-title": "Revoca tutte le API Keys",
|
||||
"revoke-key": "Revoca key",
|
||||
"revoked-at": "Revocata il",
|
||||
"show-keys": "Mostra keys",
|
||||
"sort-by": "Ordina per",
|
||||
"table": {
|
||||
"actions": "Azioni",
|
||||
"active": "Attive",
|
||||
"comments": "Commenti",
|
||||
"last-activity": "Ultima attività",
|
||||
"name": "Nome",
|
||||
"posts": "Post",
|
||||
"prefix": "Key",
|
||||
"revoked-count": "Revocate",
|
||||
"user": "Utente"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"description": "Configura i badge disponibili per questo utente",
|
||||
"noBadges": "Non ci sono badge disponibili",
|
||||
@ -1064,6 +1106,51 @@
|
||||
"title": "Risultati della ricerca"
|
||||
},
|
||||
"settings": {
|
||||
"api-keys": {
|
||||
"create": {
|
||||
"expiry-days": "{days} giorni",
|
||||
"expiry-label": "Validità",
|
||||
"expiry-never": "Nessuna scadenza",
|
||||
"limit-reached": "Hai raggiunto il massimo di {max} API Keys attive. Revoca una key esistente per crearne una nuova.",
|
||||
"name-label": "Nome",
|
||||
"submit": "Crea key",
|
||||
"success": "API Key creata con successo",
|
||||
"title": "Crea nuovo API Key"
|
||||
},
|
||||
"list": {
|
||||
"actions": "Azioni",
|
||||
"created-at": "Creata il",
|
||||
"empty": "Non hai ancora API Keys.",
|
||||
"expired": "scaduta",
|
||||
"expires": "Scade",
|
||||
"last-used": "Ultimo utilizzo",
|
||||
"name": "Nome",
|
||||
"never": "mai",
|
||||
"never-expires": "mai",
|
||||
"prefix": "Key",
|
||||
"revoke": "Revoca key",
|
||||
"revoked": "revocata",
|
||||
"title": "Le mie API Keys"
|
||||
},
|
||||
"name": "API Keys",
|
||||
"revoke": {
|
||||
"confirm": "Revoca",
|
||||
"message": "Sei sicuro di voler revocare l'API Key \"{name}\"? Questa azione non può essere annullata.",
|
||||
"success": "L'API Key \"{name}\" è stata revocata",
|
||||
"title": "Revoca API Key"
|
||||
},
|
||||
"revoked-list": {
|
||||
"revoked-at": "Revocata il",
|
||||
"title": "Keys revocate ({count})"
|
||||
},
|
||||
"secret": {
|
||||
"copied": "API Key copiata negli appunti",
|
||||
"copy": "Copia",
|
||||
"copy-failed": "Copia negli appunti non riuscita",
|
||||
"title": "Il tuo nuovo API Key (visibile solo una volta!)",
|
||||
"warning": "Salva questa key ora. Non potrai vederla di nuovo dopo aver chiuso questa pagina."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"click-to-select": "Clicca su uno spazio vuoto per aggiungere un badge.",
|
||||
"click-to-use": "Clicca su un badge per posizionarlo nello spazio selezionato.",
|
||||
|
||||
@ -14,6 +14,48 @@
|
||||
"search": "Zoeken"
|
||||
},
|
||||
"admin": {
|
||||
"api-keys": {
|
||||
"detail": {
|
||||
"active": "Actieve keys ({count})",
|
||||
"revoked": "Ingetrokken keys ({count})"
|
||||
},
|
||||
"empty": "Geen gebruikers met API Keys gevonden.",
|
||||
"name": "API Keys",
|
||||
"never": "nooit",
|
||||
"order": {
|
||||
"active-keys": "Actieve keys",
|
||||
"comments": "Aantal reacties",
|
||||
"last-used": "Laatste activiteit",
|
||||
"posts": "Aantal berichten"
|
||||
},
|
||||
"revoke": {
|
||||
"confirm": "Intrekken",
|
||||
"message": "Weet je zeker dat je de API Key \"{name}\" van {user} wilt intrekken?",
|
||||
"success": "API Key is ingetrokken",
|
||||
"title": "API Key intrekken"
|
||||
},
|
||||
"revoke-all": "Alle keys van deze gebruiker intrekken",
|
||||
"revoke-all-confirm": "Alles intrekken",
|
||||
"revoke-all-message": "Weet je zeker dat je ALLE API Keys van {user} wilt intrekken? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"revoke-all-short": "Alles intrekken",
|
||||
"revoke-all-success": "{count} API Keys van {user} zijn ingetrokken",
|
||||
"revoke-all-title": "Alle API Keys intrekken",
|
||||
"revoke-key": "Key intrekken",
|
||||
"revoked-at": "Ingetrokken op",
|
||||
"show-keys": "Keys tonen",
|
||||
"sort-by": "Sorteren op",
|
||||
"table": {
|
||||
"actions": "Acties",
|
||||
"active": "Actief",
|
||||
"comments": "Reacties",
|
||||
"last-activity": "Laatste activiteit",
|
||||
"name": "Naam",
|
||||
"posts": "Berichten",
|
||||
"prefix": "Key",
|
||||
"revoked-count": "Ingetrokken",
|
||||
"user": "Gebruiker"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"description": "Configureer de beschikbare badges voor deze gebruiker",
|
||||
"noBadges": "Er zijn geen badges beschikbaar",
|
||||
@ -1064,6 +1106,51 @@
|
||||
"title": "Zoekresultaten"
|
||||
},
|
||||
"settings": {
|
||||
"api-keys": {
|
||||
"create": {
|
||||
"expiry-days": "{days} dagen",
|
||||
"expiry-label": "Geldigheid",
|
||||
"expiry-never": "Geen vervaldatum",
|
||||
"limit-reached": "Je hebt het maximum van {max} actieve API Keys bereikt. Trek een bestaande key in om een nieuwe aan te maken.",
|
||||
"name-label": "Naam",
|
||||
"submit": "Key aanmaken",
|
||||
"success": "API Key is aangemaakt",
|
||||
"title": "Nieuwe API Key aanmaken"
|
||||
},
|
||||
"list": {
|
||||
"actions": "Acties",
|
||||
"created-at": "Aangemaakt op",
|
||||
"empty": "Je hebt nog geen API Keys.",
|
||||
"expired": "verlopen",
|
||||
"expires": "Verloopt",
|
||||
"last-used": "Laatst gebruikt",
|
||||
"name": "Naam",
|
||||
"never": "nooit",
|
||||
"never-expires": "nooit",
|
||||
"prefix": "Key",
|
||||
"revoke": "Key intrekken",
|
||||
"revoked": "ingetrokken",
|
||||
"title": "Mijn API Keys"
|
||||
},
|
||||
"name": "API Keys",
|
||||
"revoke": {
|
||||
"confirm": "Intrekken",
|
||||
"message": "Weet je zeker dat je de API Key \"{name}\" wilt intrekken? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"success": "API Key \"{name}\" is ingetrokken",
|
||||
"title": "API Key intrekken"
|
||||
},
|
||||
"revoked-list": {
|
||||
"revoked-at": "Ingetrokken op",
|
||||
"title": "Ingetrokken keys ({count})"
|
||||
},
|
||||
"secret": {
|
||||
"copied": "API Key naar klembord gekopieerd",
|
||||
"copy": "Kopiëren",
|
||||
"copy-failed": "Kopiëren naar klembord mislukt",
|
||||
"title": "Je nieuwe API Key (slechts één keer zichtbaar!)",
|
||||
"warning": "Sla deze key nu op. Je kunt hem na het sluiten van deze pagina niet meer zien."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"click-to-select": "Klik op een lege plek om een badge toe te voegen.",
|
||||
"click-to-use": "Klik op een badge om deze in de geselecteerde plek te plaatsen.",
|
||||
|
||||
@ -14,6 +14,48 @@
|
||||
"search": "Szukaj"
|
||||
},
|
||||
"admin": {
|
||||
"api-keys": {
|
||||
"detail": {
|
||||
"active": "Aktywne keys ({count})",
|
||||
"revoked": "Unieważnione keys ({count})"
|
||||
},
|
||||
"empty": "Nie znaleziono użytkowników z API Keys.",
|
||||
"name": "API Keys",
|
||||
"never": "nigdy",
|
||||
"order": {
|
||||
"active-keys": "Aktywne keys",
|
||||
"comments": "Liczba komentarzy",
|
||||
"last-used": "Ostatnia aktywność",
|
||||
"posts": "Liczba postów"
|
||||
},
|
||||
"revoke": {
|
||||
"confirm": "Unieważnij",
|
||||
"message": "Czy na pewno chcesz unieważnić API Key \"{name}\" użytkownika {user}?",
|
||||
"success": "API Key został unieważniony",
|
||||
"title": "Unieważnij API Key"
|
||||
},
|
||||
"revoke-all": "Unieważnij wszystkie keys tego użytkownika",
|
||||
"revoke-all-confirm": "Unieważnij wszystkie",
|
||||
"revoke-all-message": "Czy na pewno chcesz unieważnić WSZYSTKIE API Keys użytkownika {user}? Tej akcji nie można cofnąć.",
|
||||
"revoke-all-short": "Unieważnij wszystkie",
|
||||
"revoke-all-success": "{count} API Keys użytkownika {user} zostało unieważnionych",
|
||||
"revoke-all-title": "Unieważnij wszystkie API Keys",
|
||||
"revoke-key": "Unieważnij key",
|
||||
"revoked-at": "Unieważniony",
|
||||
"show-keys": "Pokaż keys",
|
||||
"sort-by": "Sortuj według",
|
||||
"table": {
|
||||
"actions": "Akcje",
|
||||
"active": "Aktywne",
|
||||
"comments": "Komentarze",
|
||||
"last-activity": "Ostatnia aktywność",
|
||||
"name": "Nazwa",
|
||||
"posts": "Posty",
|
||||
"prefix": "Key",
|
||||
"revoked-count": "Unieważnione",
|
||||
"user": "Użytkownik"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"description": "Skonfiguruj dostępne odznaki dla tego użytkownika",
|
||||
"noBadges": "Brak dostępnych odznak",
|
||||
@ -1064,6 +1106,51 @@
|
||||
"title": "Wyniki wyszukiwania"
|
||||
},
|
||||
"settings": {
|
||||
"api-keys": {
|
||||
"create": {
|
||||
"expiry-days": "{days} dni",
|
||||
"expiry-label": "Ważność",
|
||||
"expiry-never": "Bez daty wygaśnięcia",
|
||||
"limit-reached": "Osiągnąłeś maksymalną liczbę {max} aktywnych API Keys. Unieważnij istniejący key, aby utworzyć nowy.",
|
||||
"name-label": "Nazwa",
|
||||
"submit": "Utwórz key",
|
||||
"success": "API Key został utworzony",
|
||||
"title": "Utwórz nowy API Key"
|
||||
},
|
||||
"list": {
|
||||
"actions": "Akcje",
|
||||
"created-at": "Utworzony",
|
||||
"empty": "Nie masz jeszcze żadnych API Keys.",
|
||||
"expired": "wygasły",
|
||||
"expires": "Wygasa",
|
||||
"last-used": "Ostatnie użycie",
|
||||
"name": "Nazwa",
|
||||
"never": "nigdy",
|
||||
"never-expires": "nigdy",
|
||||
"prefix": "Key",
|
||||
"revoke": "Unieważnij key",
|
||||
"revoked": "unieważniony",
|
||||
"title": "Moje API Keys"
|
||||
},
|
||||
"name": "API Keys",
|
||||
"revoke": {
|
||||
"confirm": "Unieważnij",
|
||||
"message": "Czy na pewno chcesz unieważnić API Key \"{name}\"? Tej akcji nie można cofnąć.",
|
||||
"success": "API Key \"{name}\" został unieważniony",
|
||||
"title": "Unieważnij API Key"
|
||||
},
|
||||
"revoked-list": {
|
||||
"revoked-at": "Unieważniony",
|
||||
"title": "Unieważnione keys ({count})"
|
||||
},
|
||||
"secret": {
|
||||
"copied": "API Key skopiowany do schowka",
|
||||
"copy": "Kopiuj",
|
||||
"copy-failed": "Nie udało się skopiować do schowka",
|
||||
"title": "Twój nowy API Key (widoczny tylko raz!)",
|
||||
"warning": "Zapisz ten key teraz. Nie będziesz mógł go zobaczyć ponownie po zamknięciu tej strony."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"click-to-select": "Kliknij na puste miejsce, aby dodać odznakę.",
|
||||
"click-to-use": "Kliknij na odznakę, aby umieścić ją w wybranym miejscu.",
|
||||
|
||||
@ -14,6 +14,48 @@
|
||||
"search": "Pesquisar"
|
||||
},
|
||||
"admin": {
|
||||
"api-keys": {
|
||||
"detail": {
|
||||
"active": "Keys ativas ({count})",
|
||||
"revoked": "Keys revogadas ({count})"
|
||||
},
|
||||
"empty": "Nenhum utilizador com API Keys encontrado.",
|
||||
"name": "API Keys",
|
||||
"never": "nunca",
|
||||
"order": {
|
||||
"active-keys": "Keys ativas",
|
||||
"comments": "Número de comentários",
|
||||
"last-used": "Última atividade",
|
||||
"posts": "Número de publicações"
|
||||
},
|
||||
"revoke": {
|
||||
"confirm": "Revogar",
|
||||
"message": "Tens a certeza de que queres revogar o API Key \"{name}\" de {user}?",
|
||||
"success": "API Key revogada com sucesso",
|
||||
"title": "Revogar API Key"
|
||||
},
|
||||
"revoke-all": "Revogar todas as keys deste utilizador",
|
||||
"revoke-all-confirm": "Revogar todas",
|
||||
"revoke-all-message": "Tens a certeza de que queres revogar TODAS as API Keys de {user}? Esta ação não pode ser desfeita.",
|
||||
"revoke-all-short": "Revogar todas",
|
||||
"revoke-all-success": "{count} API Keys de {user} foram revogadas",
|
||||
"revoke-all-title": "Revogar todas as API Keys",
|
||||
"revoke-key": "Revogar key",
|
||||
"revoked-at": "Revogada em",
|
||||
"show-keys": "Mostrar keys",
|
||||
"sort-by": "Ordenar por",
|
||||
"table": {
|
||||
"actions": "Ações",
|
||||
"active": "Ativas",
|
||||
"comments": "Comentários",
|
||||
"last-activity": "Última atividade",
|
||||
"name": "Nome",
|
||||
"posts": "Publicações",
|
||||
"prefix": "Key",
|
||||
"revoked-count": "Revogadas",
|
||||
"user": "Utilizador"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"description": "Configurar os emblemas disponíveis para este usuário",
|
||||
"noBadges": "Não há emblemas disponíveis",
|
||||
@ -1064,6 +1106,51 @@
|
||||
"title": "Resultados da busca"
|
||||
},
|
||||
"settings": {
|
||||
"api-keys": {
|
||||
"create": {
|
||||
"expiry-days": "{days} dias",
|
||||
"expiry-label": "Validade",
|
||||
"expiry-never": "Sem data de expiração",
|
||||
"limit-reached": "Atingiste o máximo de {max} API Keys ativas. Revoga uma key existente para criar uma nova.",
|
||||
"name-label": "Nome",
|
||||
"submit": "Criar key",
|
||||
"success": "API Key criada com sucesso",
|
||||
"title": "Criar novo API Key"
|
||||
},
|
||||
"list": {
|
||||
"actions": "Ações",
|
||||
"created-at": "Criada em",
|
||||
"empty": "Ainda não tens API Keys.",
|
||||
"expired": "expirada",
|
||||
"expires": "Expira",
|
||||
"last-used": "Última utilização",
|
||||
"name": "Nome",
|
||||
"never": "nunca",
|
||||
"never-expires": "nunca",
|
||||
"prefix": "Key",
|
||||
"revoke": "Revogar key",
|
||||
"revoked": "revogada",
|
||||
"title": "As minhas API Keys"
|
||||
},
|
||||
"name": "API Keys",
|
||||
"revoke": {
|
||||
"confirm": "Revogar",
|
||||
"message": "Tens a certeza de que queres revogar o API Key \"{name}\"? Esta ação não pode ser desfeita.",
|
||||
"success": "API Key \"{name}\" foi revogada",
|
||||
"title": "Revogar API Key"
|
||||
},
|
||||
"revoked-list": {
|
||||
"revoked-at": "Revogada em",
|
||||
"title": "Keys revogadas ({count})"
|
||||
},
|
||||
"secret": {
|
||||
"copied": "API Key copiada para a área de transferência",
|
||||
"copy": "Copiar",
|
||||
"copy-failed": "Falha ao copiar para a área de transferência",
|
||||
"title": "O teu novo API Key (visível apenas uma vez!)",
|
||||
"warning": "Guarda esta key agora. Não a poderás ver novamente depois de fechar esta página."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"click-to-select": "Clique num espaço vazio para adicionar uma medalha.",
|
||||
"click-to-use": "Clique numa medalha para a colocar no espaço selecionado.",
|
||||
|
||||
@ -14,6 +14,48 @@
|
||||
"search": "Поиск"
|
||||
},
|
||||
"admin": {
|
||||
"api-keys": {
|
||||
"detail": {
|
||||
"active": "Активные keys ({count})",
|
||||
"revoked": "Отозванные keys ({count})"
|
||||
},
|
||||
"empty": "Пользователи с API Keys не найдены.",
|
||||
"name": "API Keys",
|
||||
"never": "никогда",
|
||||
"order": {
|
||||
"active-keys": "Активные keys",
|
||||
"comments": "Количество комментариев",
|
||||
"last-used": "Последняя активность",
|
||||
"posts": "Количество постов"
|
||||
},
|
||||
"revoke": {
|
||||
"confirm": "Отозвать",
|
||||
"message": "Ты уверен, что хочешь отозвать API Key \"{name}\" пользователя {user}?",
|
||||
"success": "API Key успешно отозван",
|
||||
"title": "Отозвать API Key"
|
||||
},
|
||||
"revoke-all": "Отозвать все keys этого пользователя",
|
||||
"revoke-all-confirm": "Отозвать все",
|
||||
"revoke-all-message": "Ты уверен, что хочешь отозвать ВСЕ API Keys пользователя {user}? Это действие нельзя отменить.",
|
||||
"revoke-all-short": "Отозвать все",
|
||||
"revoke-all-success": "{count} API Keys пользователя {user} были отозваны",
|
||||
"revoke-all-title": "Отозвать все API Keys",
|
||||
"revoke-key": "Отозвать key",
|
||||
"revoked-at": "Отозван",
|
||||
"show-keys": "Показать keys",
|
||||
"sort-by": "Сортировать по",
|
||||
"table": {
|
||||
"actions": "Действия",
|
||||
"active": "Активные",
|
||||
"comments": "Комментарии",
|
||||
"last-activity": "Последняя активность",
|
||||
"name": "Имя",
|
||||
"posts": "Посты",
|
||||
"prefix": "Key",
|
||||
"revoked-count": "Отозванные",
|
||||
"user": "Пользователь"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"description": "Настроить доступные значки для этого пользователя",
|
||||
"noBadges": "Нет доступных значков",
|
||||
@ -1064,6 +1106,51 @@
|
||||
"title": "Результаты поиска"
|
||||
},
|
||||
"settings": {
|
||||
"api-keys": {
|
||||
"create": {
|
||||
"expiry-days": "{days} дней",
|
||||
"expiry-label": "Срок действия",
|
||||
"expiry-never": "Без срока действия",
|
||||
"limit-reached": "Ты достиг максимума в {max} активных API Keys. Отзови существующий key, чтобы создать новый.",
|
||||
"name-label": "Имя",
|
||||
"submit": "Создать key",
|
||||
"success": "API Key успешно создан",
|
||||
"title": "Создать новый API Key"
|
||||
},
|
||||
"list": {
|
||||
"actions": "Действия",
|
||||
"created-at": "Создан",
|
||||
"empty": "У тебя ещё нет API Keys.",
|
||||
"expired": "истёк",
|
||||
"expires": "Истекает",
|
||||
"last-used": "Последнее использование",
|
||||
"name": "Имя",
|
||||
"never": "никогда",
|
||||
"never-expires": "никогда",
|
||||
"prefix": "Key",
|
||||
"revoke": "Отозвать key",
|
||||
"revoked": "отозван",
|
||||
"title": "Мои API Keys"
|
||||
},
|
||||
"name": "API Keys",
|
||||
"revoke": {
|
||||
"confirm": "Отозвать",
|
||||
"message": "Ты уверен, что хочешь отозвать API Key \"{name}\"? Это действие нельзя отменить.",
|
||||
"success": "API Key \"{name}\" был отозван",
|
||||
"title": "Отозвать API Key"
|
||||
},
|
||||
"revoked-list": {
|
||||
"revoked-at": "Отозван",
|
||||
"title": "Отозванные keys ({count})"
|
||||
},
|
||||
"secret": {
|
||||
"copied": "API Key скопирован в буфер обмена",
|
||||
"copy": "Копировать",
|
||||
"copy-failed": "Не удалось скопировать в буфер обмена",
|
||||
"title": "Твой новый API Key (виден только один раз!)",
|
||||
"warning": "Сохрани этот key сейчас. Ты не сможешь увидеть его снова после закрытия этой страницы."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"click-to-select": "Нажмите на пустое место, чтобы добавить награду.",
|
||||
"click-to-use": "Нажмите на награду, чтобы поместить её в выбранное место.",
|
||||
|
||||
@ -14,6 +14,48 @@
|
||||
"search": "Kërko"
|
||||
},
|
||||
"admin": {
|
||||
"api-keys": {
|
||||
"detail": {
|
||||
"active": "Keys aktive ({count})",
|
||||
"revoked": "Keys të revokuara ({count})"
|
||||
},
|
||||
"empty": "Nuk u gjetën përdorues me API Keys.",
|
||||
"name": "API Keys",
|
||||
"never": "kurrë",
|
||||
"order": {
|
||||
"active-keys": "Keys aktive",
|
||||
"comments": "Numri i komenteve",
|
||||
"last-used": "Aktiviteti i fundit",
|
||||
"posts": "Numri i postimeve"
|
||||
},
|
||||
"revoke": {
|
||||
"confirm": "Revoko",
|
||||
"message": "A je i sigurt që dëshiron të revokosh API Key \"{name}\" të {user}?",
|
||||
"success": "API Key u revokua me sukses",
|
||||
"title": "Revoko API Key"
|
||||
},
|
||||
"revoke-all": "Revoko të gjitha keys e këtij përdoruesi",
|
||||
"revoke-all-confirm": "Revoko të gjitha",
|
||||
"revoke-all-message": "A je i sigurt që dëshiron të revokosh TË GJITHA API Keys e {user}? Ky veprim nuk mund të zhbëhet.",
|
||||
"revoke-all-short": "Revoko të gjitha",
|
||||
"revoke-all-success": "{count} API Keys të {user} u revokuan",
|
||||
"revoke-all-title": "Revoko të gjitha API Keys",
|
||||
"revoke-key": "Revoko key",
|
||||
"revoked-at": "Revokuar më",
|
||||
"show-keys": "Shfaq keys",
|
||||
"sort-by": "Rendit sipas",
|
||||
"table": {
|
||||
"actions": "Veprime",
|
||||
"active": "Aktive",
|
||||
"comments": "Komente",
|
||||
"last-activity": "Aktiviteti i fundit",
|
||||
"name": "Emri",
|
||||
"posts": "Postime",
|
||||
"prefix": "Key",
|
||||
"revoked-count": "Revokuara",
|
||||
"user": "Përdoruesi"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"description": "Konfiguro embelemat e disponueshme për këtë përdorues",
|
||||
"noBadges": "Nuk ka emblema të disponueshme",
|
||||
@ -1064,6 +1106,51 @@
|
||||
"title": "Rezultatet e kërkimit"
|
||||
},
|
||||
"settings": {
|
||||
"api-keys": {
|
||||
"create": {
|
||||
"expiry-days": "{days} ditë",
|
||||
"expiry-label": "Vlefshmëria",
|
||||
"expiry-never": "Pa datë skadimi",
|
||||
"limit-reached": "Ke arritur maksimumin e {max} API Keys aktive. Revoko një key ekzistues për të krijuar një të ri.",
|
||||
"name-label": "Emri",
|
||||
"submit": "Krijo key",
|
||||
"success": "API Key u krijua me sukses",
|
||||
"title": "Krijo API Key të ri"
|
||||
},
|
||||
"list": {
|
||||
"actions": "Veprime",
|
||||
"created-at": "Krijuar më",
|
||||
"empty": "Nuk ke ende API Keys.",
|
||||
"expired": "skaduar",
|
||||
"expires": "Skadon",
|
||||
"last-used": "Përdorimi i fundit",
|
||||
"name": "Emri",
|
||||
"never": "kurrë",
|
||||
"never-expires": "kurrë",
|
||||
"prefix": "Key",
|
||||
"revoke": "Revoko key",
|
||||
"revoked": "revokuar",
|
||||
"title": "API Keys e mia"
|
||||
},
|
||||
"name": "API Keys",
|
||||
"revoke": {
|
||||
"confirm": "Revoko",
|
||||
"message": "A je i sigurt që dëshiron të revokosh API Key \"{name}\"? Ky veprim nuk mund të zhbëhet.",
|
||||
"success": "API Key \"{name}\" u revokua",
|
||||
"title": "Revoko API Key"
|
||||
},
|
||||
"revoked-list": {
|
||||
"revoked-at": "Revokuar më",
|
||||
"title": "Keys të revokuara ({count})"
|
||||
},
|
||||
"secret": {
|
||||
"copied": "API Key u kopjua në clipboard",
|
||||
"copy": "Kopjo",
|
||||
"copy-failed": "Kopjimi në clipboard dështoi",
|
||||
"title": "API Key yt i ri (i dukshëm vetëm një herë!)",
|
||||
"warning": "Ruaje këtë key tani. Nuk do të mundesh ta shohësh përsëri pasi të mbyllësh këtë faqe."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"click-to-select": "Kliko në një hapësirë boshe për të shtuar një emblemë.",
|
||||
"click-to-use": "Kliko mbi një emblemë për ta përdorur në hapësirën e zgjedhur.",
|
||||
|
||||
@ -14,6 +14,48 @@
|
||||
"search": "Пошук"
|
||||
},
|
||||
"admin": {
|
||||
"api-keys": {
|
||||
"detail": {
|
||||
"active": "Активні keys ({count})",
|
||||
"revoked": "Відкликані keys ({count})"
|
||||
},
|
||||
"empty": "Користувачів з API Keys не знайдено.",
|
||||
"name": "API Keys",
|
||||
"never": "ніколи",
|
||||
"order": {
|
||||
"active-keys": "Активні keys",
|
||||
"comments": "Кількість коментарів",
|
||||
"last-used": "Остання активність",
|
||||
"posts": "Кількість постів"
|
||||
},
|
||||
"revoke": {
|
||||
"confirm": "Відкликати",
|
||||
"message": "Ти впевнений, що хочеш відкликати API Key \"{name}\" користувача {user}?",
|
||||
"success": "API Key успішно відкликано",
|
||||
"title": "Відкликати API Key"
|
||||
},
|
||||
"revoke-all": "Відкликати всі keys цього користувача",
|
||||
"revoke-all-confirm": "Відкликати всі",
|
||||
"revoke-all-message": "Ти впевнений, що хочеш відкликати ВСІ API Keys користувача {user}? Цю дію не можна скасувати.",
|
||||
"revoke-all-short": "Відкликати всі",
|
||||
"revoke-all-success": "{count} API Keys користувача {user} було відкликано",
|
||||
"revoke-all-title": "Відкликати всі API Keys",
|
||||
"revoke-key": "Відкликати key",
|
||||
"revoked-at": "Відкликано",
|
||||
"show-keys": "Показати keys",
|
||||
"sort-by": "Сортувати за",
|
||||
"table": {
|
||||
"actions": "Дії",
|
||||
"active": "Активні",
|
||||
"comments": "Коментарі",
|
||||
"last-activity": "Остання активність",
|
||||
"name": "Ім'я",
|
||||
"posts": "Пости",
|
||||
"prefix": "Key",
|
||||
"revoked-count": "Відкликані",
|
||||
"user": "Користувач"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"description": "Налаштувати доступні значки для цього користувача",
|
||||
"noBadges": "Немає доступних значків",
|
||||
@ -1064,6 +1106,51 @@
|
||||
"title": "Результати пошуку"
|
||||
},
|
||||
"settings": {
|
||||
"api-keys": {
|
||||
"create": {
|
||||
"expiry-days": "{days} днів",
|
||||
"expiry-label": "Термін дії",
|
||||
"expiry-never": "Без терміну дії",
|
||||
"limit-reached": "Ти досяг максимуму в {max} активних API Keys. Відклич існуючий key, щоб створити новий.",
|
||||
"name-label": "Ім'я",
|
||||
"submit": "Створити key",
|
||||
"success": "API Key успішно створено",
|
||||
"title": "Створити новий API Key"
|
||||
},
|
||||
"list": {
|
||||
"actions": "Дії",
|
||||
"created-at": "Створено",
|
||||
"empty": "У тебе ще немає API Keys.",
|
||||
"expired": "закінчився",
|
||||
"expires": "Закінчується",
|
||||
"last-used": "Останнє використання",
|
||||
"name": "Ім'я",
|
||||
"never": "ніколи",
|
||||
"never-expires": "ніколи",
|
||||
"prefix": "Key",
|
||||
"revoke": "Відкликати key",
|
||||
"revoked": "відкликано",
|
||||
"title": "Мої API Keys"
|
||||
},
|
||||
"name": "API Keys",
|
||||
"revoke": {
|
||||
"confirm": "Відкликати",
|
||||
"message": "Ти впевнений, що хочеш відкликати API Key \"{name}\"? Цю дію не можна скасувати.",
|
||||
"success": "API Key \"{name}\" було відкликано",
|
||||
"title": "Відкликати API Key"
|
||||
},
|
||||
"revoked-list": {
|
||||
"revoked-at": "Відкликано",
|
||||
"title": "Відкликані keys ({count})"
|
||||
},
|
||||
"secret": {
|
||||
"copied": "API Key скопійовано до буфера обміну",
|
||||
"copy": "Копіювати",
|
||||
"copy-failed": "Не вдалося скопіювати до буфера обміну",
|
||||
"title": "Твій новий API Key (видимий лише один раз!)",
|
||||
"warning": "Збережи цей key зараз. Ти не зможеш побачити його знову після закриття цієї сторінки."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"click-to-select": "Натисніть на порожнє місце, щоб додати значок.",
|
||||
"click-to-use": "Натисніть на значок, щоб використати його у вибраному місці.",
|
||||
|
||||
@ -14,6 +14,7 @@ describe('admin.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$t: jest.fn(),
|
||||
$env: { API_KEYS_ENABLED: false },
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -64,6 +64,9 @@ export default {
|
||||
name: this.$t('admin.donations.name'),
|
||||
path: '/admin/donations',
|
||||
},
|
||||
...(this.$env.API_KEYS_ENABLED
|
||||
? [{ name: this.$t('admin.api-keys.name'), path: `/admin/api-keys` }]
|
||||
: []),
|
||||
// TODO implement
|
||||
/* {
|
||||
name: this.$t('admin.settings.name'),
|
||||
|
||||
393
webapp/pages/admin/api-keys.spec.js
Normal file
393
webapp/pages/admin/api-keys.spec.js
Normal file
@ -0,0 +1,393 @@
|
||||
import Vue from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import flushPromises from 'flush-promises'
|
||||
import ApiKeys from './api-keys.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
// Override dateTime filter to avoid locale dependency
|
||||
Vue.filter('dateTime', (value) => value || '')
|
||||
|
||||
describe('admin/api-keys.vue', () => {
|
||||
let wrapper, mocks
|
||||
|
||||
const mutateMock = jest.fn()
|
||||
const refetchMock = jest.fn()
|
||||
const queryMock = jest.fn()
|
||||
|
||||
const userEntry = (overrides = {}) => ({
|
||||
user: { id: 'u1', name: 'Peter', slug: 'peter' },
|
||||
activeCount: 2,
|
||||
revokedCount: 1,
|
||||
postsCount: 42,
|
||||
commentsCount: 15,
|
||||
lastActivity: '2026-04-02T12:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const userEntryNeverUsed = () =>
|
||||
userEntry({
|
||||
user: { id: 'u2', name: 'Bob', slug: 'bob' },
|
||||
activeCount: 1,
|
||||
revokedCount: 0,
|
||||
postsCount: 0,
|
||||
commentsCount: 0,
|
||||
lastActivity: null,
|
||||
})
|
||||
|
||||
const activeKeyDetail = (overrides = {}) => ({
|
||||
id: 'ak1',
|
||||
name: 'CI Bot',
|
||||
keyPrefix: 'oak_cibot123',
|
||||
createdAt: '2026-03-01T00:00:00Z',
|
||||
lastUsedAt: '2026-04-02T12:00:00Z',
|
||||
expiresAt: null,
|
||||
disabled: false,
|
||||
disabledAt: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const revokedKeyDetail = (overrides = {}) => ({
|
||||
id: 'ak-old',
|
||||
name: 'Old Script',
|
||||
keyPrefix: 'oak_old12345',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
lastUsedAt: '2025-05-01T00:00:00Z',
|
||||
expiresAt: null,
|
||||
disabled: true,
|
||||
disabledAt: '2025-06-01T00:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mutateMock.mockReset()
|
||||
refetchMock.mockReset()
|
||||
queryMock.mockReset()
|
||||
|
||||
mocks = {
|
||||
$t: jest.fn((key) => key),
|
||||
$toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
$apollo: {
|
||||
mutate: mutateMock,
|
||||
query: queryMock,
|
||||
queries: {
|
||||
apiKeyUsers: {
|
||||
refetch: refetchMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const Wrapper = (data = {}) => {
|
||||
return mount(ApiKeys, {
|
||||
mocks,
|
||||
localVue,
|
||||
stubs: {
|
||||
'nuxt-link': true,
|
||||
'confirm-modal': true,
|
||||
'pagination-buttons': true,
|
||||
'user-teaser': { template: '<span>@{{ user.slug }}</span>', props: ['user'] },
|
||||
'date-time': { template: '<span>{{ dateTime }}</span>', props: ['dateTime'] },
|
||||
'os-spinner': true,
|
||||
},
|
||||
data: () => ({
|
||||
apiKeyUsers: [],
|
||||
...data,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
describe('renders', () => {
|
||||
it('shows title', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.text()).toContain('admin.api-keys.name')
|
||||
})
|
||||
|
||||
it('shows empty state when no users', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.text()).toContain('admin.api-keys.empty')
|
||||
})
|
||||
|
||||
it('shows user table', () => {
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
expect(wrapper.text()).toContain('@peter')
|
||||
expect(wrapper.text()).toContain('42')
|
||||
expect(wrapper.text()).toContain('15')
|
||||
})
|
||||
|
||||
it('shows "never" for null lastActivity', () => {
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntryNeverUsed()] })
|
||||
expect(wrapper.text()).toContain('admin.api-keys.never')
|
||||
})
|
||||
|
||||
it('shows revoke-all button only when active keys exist', () => {
|
||||
wrapper = Wrapper({
|
||||
apiKeyUsers: [userEntry({ activeCount: 0, revokedCount: 3 })],
|
||||
})
|
||||
expect(wrapper.find('button[aria-label="admin.api-keys.revoke-all"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows revoke-all button when active keys exist', () => {
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
expect(wrapper.find('button[aria-label="admin.api-keys.revoke-all"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('expand user detail', () => {
|
||||
it('loads keys on expand click', async () => {
|
||||
queryMock.mockResolvedValue({
|
||||
data: {
|
||||
apiKeysForUser: [activeKeyDetail(), revokedKeyDetail()],
|
||||
},
|
||||
})
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
await wrapper.find('button[aria-label="admin.api-keys.show-keys"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.expandedUserId).toBe('u1')
|
||||
expect(wrapper.vm.userKeys).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('separates active and revoked keys', async () => {
|
||||
queryMock.mockResolvedValue({
|
||||
data: {
|
||||
apiKeysForUser: [activeKeyDetail(), revokedKeyDetail()],
|
||||
},
|
||||
})
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
await wrapper.find('button[aria-label="admin.api-keys.show-keys"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.activeUserKeys).toHaveLength(1)
|
||||
expect(wrapper.vm.activeUserKeys[0].name).toBe('CI Bot')
|
||||
expect(wrapper.vm.revokedUserKeys).toHaveLength(1)
|
||||
expect(wrapper.vm.revokedUserKeys[0].name).toBe('Old Script')
|
||||
})
|
||||
|
||||
it('collapses on second click', async () => {
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
await wrapper.setData({
|
||||
expandedUserId: 'u1',
|
||||
userKeys: [activeKeyDetail()],
|
||||
})
|
||||
await wrapper.vm.toggleUser('u1')
|
||||
expect(wrapper.vm.expandedUserId).toBeNull()
|
||||
expect(wrapper.vm.userKeys).toBeNull()
|
||||
})
|
||||
|
||||
it('shows detail section with active keys heading', async () => {
|
||||
queryMock.mockResolvedValue({
|
||||
data: { apiKeysForUser: [activeKeyDetail()] },
|
||||
})
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
await wrapper.vm.toggleUser('u1')
|
||||
await flushPromises()
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('admin.api-keys.detail.active')
|
||||
expect(wrapper.text()).toContain('CI Bot')
|
||||
})
|
||||
|
||||
it('shows revoked keys heading in detail', async () => {
|
||||
queryMock.mockResolvedValue({
|
||||
data: { apiKeysForUser: [revokedKeyDetail()] },
|
||||
})
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry({ activeCount: 0, revokedCount: 1 })] })
|
||||
await wrapper.vm.toggleUser('u1')
|
||||
await flushPromises()
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('admin.api-keys.detail.revoked')
|
||||
})
|
||||
|
||||
it('shows error toast on query failure', async () => {
|
||||
queryMock.mockRejectedValue(new Error('Query failed'))
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
await wrapper.vm.toggleUser('u1')
|
||||
await flushPromises()
|
||||
expect(mocks.$toast.error).toHaveBeenCalledWith('Query failed')
|
||||
})
|
||||
|
||||
it('discards stale response when user switches during load', async () => {
|
||||
let resolveFirst
|
||||
queryMock
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
data: { apiKeysForUser: [activeKeyDetail({ id: 'ak-second', name: 'Second' })] },
|
||||
})
|
||||
wrapper = Wrapper({
|
||||
apiKeyUsers: [userEntry(), userEntry({ user: { id: 'u2', name: 'Bob', slug: 'bob' } })],
|
||||
})
|
||||
// Start loading user u1
|
||||
const firstToggle = wrapper.vm.toggleUser('u1')
|
||||
await wrapper.vm.$nextTick()
|
||||
// Switch to u2 before u1 finishes
|
||||
await wrapper.vm.toggleUser('u2')
|
||||
await flushPromises()
|
||||
// Now resolve u1's stale response
|
||||
resolveFirst({
|
||||
data: { apiKeysForUser: [activeKeyDetail({ id: 'ak-first', name: 'Stale' })] },
|
||||
})
|
||||
await firstToggle
|
||||
await flushPromises()
|
||||
// Should show u2's keys, not u1's stale response
|
||||
expect(wrapper.vm.expandedUserId).toBe('u2')
|
||||
expect(wrapper.vm.userKeys[0].name).toBe('Second')
|
||||
})
|
||||
|
||||
it('sets detailLoading on the expand button', async () => {
|
||||
let resolveQuery
|
||||
queryMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveQuery = resolve
|
||||
}),
|
||||
)
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
const togglePromise = wrapper.vm.toggleUser('u1')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.vm.detailLoading).toBe(true)
|
||||
resolveQuery({ data: { apiKeysForUser: [activeKeyDetail()] } })
|
||||
await togglePromise
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.detailLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('revoke single key', () => {
|
||||
it('opens confirm modal', () => {
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
const key = activeKeyDetail()
|
||||
const entry = userEntry()
|
||||
wrapper.vm.confirmRevokeKey(key, entry)
|
||||
expect(wrapper.vm.showModal).toBe(true)
|
||||
expect(wrapper.vm.modalData.messageParams).toEqual({
|
||||
name: 'CI Bot',
|
||||
user: 'Peter',
|
||||
})
|
||||
})
|
||||
|
||||
it('calls adminRevokeApiKey and refetches', async () => {
|
||||
mutateMock.mockResolvedValue({ data: { adminRevokeApiKey: true } })
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
await wrapper.vm.revokeKey('ak1', 'u1')
|
||||
await flushPromises()
|
||||
expect(mutateMock).toHaveBeenCalledWith(expect.objectContaining({ variables: { id: 'ak1' } }))
|
||||
expect(refetchMock).toHaveBeenCalled()
|
||||
expect(mocks.$toast.success).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('collapses detail after revoke', async () => {
|
||||
mutateMock.mockResolvedValue({ data: { adminRevokeApiKey: true } })
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
await wrapper.setData({ expandedUserId: 'u1' })
|
||||
await wrapper.vm.revokeKey('ak1', 'u1')
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.expandedUserId).toBeNull()
|
||||
})
|
||||
|
||||
it('shows error toast on failure', async () => {
|
||||
mutateMock.mockRejectedValue(new Error('Revoke failed'))
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
await expect(wrapper.vm.revokeKey('ak1', 'u1')).rejects.toThrow('Revoke failed')
|
||||
await flushPromises()
|
||||
expect(mocks.$toast.error).toHaveBeenCalledWith('Revoke failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('revoke all keys', () => {
|
||||
it('opens confirm modal', () => {
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
wrapper.vm.confirmRevokeAll(userEntry())
|
||||
expect(wrapper.vm.showModal).toBe(true)
|
||||
expect(wrapper.vm.modalData.messageParams).toEqual({ user: 'Peter' })
|
||||
})
|
||||
|
||||
it('calls adminRevokeUserApiKeys and refetches', async () => {
|
||||
mutateMock.mockResolvedValue({ data: { adminRevokeUserApiKeys: 2 } })
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
await wrapper.vm.revokeAllKeys('u1', 'Peter')
|
||||
await flushPromises()
|
||||
expect(mutateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ variables: { userId: 'u1' } }),
|
||||
)
|
||||
expect(refetchMock).toHaveBeenCalled()
|
||||
expect(mocks.$toast.success).toHaveBeenCalledWith('admin.api-keys.revoke-all-success')
|
||||
})
|
||||
|
||||
it('collapses detail and clears keys after bulk revoke', async () => {
|
||||
mutateMock.mockResolvedValue({ data: { adminRevokeUserApiKeys: 2 } })
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
await wrapper.setData({ expandedUserId: 'u1', userKeys: [activeKeyDetail()] })
|
||||
await wrapper.vm.revokeAllKeys('u1', 'Peter')
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.expandedUserId).toBeNull()
|
||||
expect(wrapper.vm.userKeys).toBeNull()
|
||||
})
|
||||
|
||||
it('shows error toast on failure', async () => {
|
||||
mutateMock.mockRejectedValue(new Error('Bulk revoke failed'))
|
||||
wrapper = Wrapper({ apiKeyUsers: [userEntry()] })
|
||||
await expect(wrapper.vm.revokeAllKeys('u1', 'Peter')).rejects.toThrow('Bulk revoke failed')
|
||||
await flushPromises()
|
||||
expect(mocks.$toast.error).toHaveBeenCalledWith('Bulk revoke failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pagination', () => {
|
||||
it('next increments offset', () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.next()
|
||||
expect(wrapper.vm.offset).toBe(20)
|
||||
})
|
||||
|
||||
it('back decrements offset', async () => {
|
||||
wrapper = Wrapper()
|
||||
await wrapper.setData({ offset: 20 })
|
||||
wrapper.vm.back()
|
||||
expect(wrapper.vm.offset).toBe(0)
|
||||
})
|
||||
|
||||
it('back does not go below 0', () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.back()
|
||||
expect(wrapper.vm.offset).toBe(0)
|
||||
})
|
||||
|
||||
it('hasPrevious is false at offset 0', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.vm.hasPrevious).toBe(false)
|
||||
})
|
||||
|
||||
it('hasPrevious is true at offset > 0', async () => {
|
||||
wrapper = Wrapper()
|
||||
await wrapper.setData({ offset: 20 })
|
||||
expect(wrapper.vm.hasPrevious).toBe(true)
|
||||
})
|
||||
|
||||
it('sets hasNext to true when more results than pageSize', () => {
|
||||
wrapper = Wrapper()
|
||||
const entries = Array.from({ length: 21 }, (_, i) =>
|
||||
userEntry({ user: { id: `u${i}`, name: `User ${i}`, slug: `user-${i}` } }),
|
||||
)
|
||||
const updateFn = wrapper.vm.$options.apollo.apiKeyUsers.update.bind(wrapper.vm)
|
||||
const result = updateFn({ apiKeyUsers: entries })
|
||||
expect(wrapper.vm.hasNext).toBe(true)
|
||||
expect(result).toHaveLength(20)
|
||||
})
|
||||
|
||||
it('sets hasNext to false when results fit in page', () => {
|
||||
wrapper = Wrapper()
|
||||
const entries = [userEntry()]
|
||||
const updateFn = wrapper.vm.$options.apollo.apiKeyUsers.update.bind(wrapper.vm)
|
||||
const result = updateFn({ apiKeyUsers: entries })
|
||||
expect(wrapper.vm.hasNext).toBe(false)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
398
webapp/pages/admin/api-keys.vue
Normal file
398
webapp/pages/admin/api-keys.vue
Normal file
@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<div class="admin-api-keys">
|
||||
<os-card>
|
||||
<h2 class="title">{{ $t('admin.api-keys.name') }}</h2>
|
||||
</os-card>
|
||||
|
||||
<os-card v-if="apiKeyUsers && apiKeyUsers.length">
|
||||
<div class="ds-table-wrap table-no-clip">
|
||||
<table class="ds-table ds-table-condensed ds-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="ds-table-head-col">
|
||||
{{ $t('admin.api-keys.table.user') }}
|
||||
</th>
|
||||
<th scope="col" class="ds-table-head-col ds-table-head-col-right">
|
||||
{{ $t('admin.api-keys.table.active') }}
|
||||
</th>
|
||||
<th scope="col" class="ds-table-head-col ds-table-head-col-right">
|
||||
{{ $t('admin.api-keys.table.revoked-count') }}
|
||||
</th>
|
||||
<th scope="col" class="ds-table-head-col ds-table-head-col-right">
|
||||
{{ $t('admin.api-keys.table.posts') }}
|
||||
</th>
|
||||
<th scope="col" class="ds-table-head-col ds-table-head-col-right">
|
||||
{{ $t('admin.api-keys.table.comments') }}
|
||||
</th>
|
||||
<th scope="col" class="ds-table-head-col">
|
||||
{{ $t('admin.api-keys.table.last-activity') }}
|
||||
</th>
|
||||
<th scope="col" class="ds-table-head-col">
|
||||
{{ $t('admin.api-keys.table.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="entry in apiKeyUsers">
|
||||
<tr :key="entry.user.id">
|
||||
<td class="ds-table-col">
|
||||
<user-teaser :user="entry.user" :show-slug="true" />
|
||||
</td>
|
||||
<td class="ds-table-col ds-table-col-right">{{ entry.activeCount }}</td>
|
||||
<td class="ds-table-col ds-table-col-right">{{ entry.revokedCount }}</td>
|
||||
<td class="ds-table-col ds-table-col-right">{{ entry.postsCount }}</td>
|
||||
<td class="ds-table-col ds-table-col-right">{{ entry.commentsCount }}</td>
|
||||
<td class="ds-table-col">
|
||||
<date-time v-if="entry.lastActivity" :date-time="entry.lastActivity" />
|
||||
<template v-else>{{ $t('admin.api-keys.never') }}</template>
|
||||
</td>
|
||||
<td class="ds-table-col actions-cell">
|
||||
<div class="action-buttons">
|
||||
<os-button
|
||||
v-if="entry.activeCount > 0"
|
||||
variant="danger"
|
||||
appearance="outline"
|
||||
size="sm"
|
||||
:aria-label="$t('admin.api-keys.revoke-all')"
|
||||
@click="confirmRevokeAll(entry)"
|
||||
>
|
||||
{{ $t('admin.api-keys.revoke-all-short') }}
|
||||
</os-button>
|
||||
<os-button
|
||||
variant="primary"
|
||||
appearance="outline"
|
||||
circle
|
||||
size="sm"
|
||||
:loading="detailLoading && expandedUserId === entry.user.id"
|
||||
:aria-label="$t('admin.api-keys.show-keys')"
|
||||
@click="toggleUser(entry.user.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<os-icon
|
||||
:icon="expandedUserId === entry.user.id ? icons.angleUp : icons.angleDown"
|
||||
/>
|
||||
</template>
|
||||
</os-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Expanded: keys for this user -->
|
||||
<tr v-if="expandedUserId === entry.user.id" :key="entry.user.id + '-detail'">
|
||||
<td :colspan="7" class="detail-cell">
|
||||
<div v-if="detailLoading" class="ds-placeholder">
|
||||
<os-spinner />
|
||||
</div>
|
||||
<template v-else-if="userKeys">
|
||||
<!-- Active keys -->
|
||||
<div v-if="activeUserKeys.length" class="ds-mb-small">
|
||||
<h4 class="ds-mb-small">
|
||||
{{ $t('admin.api-keys.detail.active', { count: activeUserKeys.length }) }}
|
||||
</h4>
|
||||
<table class="ds-table ds-table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ds-table-head-col">{{ $t('admin.api-keys.table.name') }}</th>
|
||||
<th class="ds-table-head-col">
|
||||
{{ $t('admin.api-keys.table.prefix') }}
|
||||
</th>
|
||||
<th class="ds-table-head-col">
|
||||
{{ $t('admin.api-keys.table.last-activity') }}
|
||||
</th>
|
||||
<th class="ds-table-head-col">
|
||||
{{ $t('admin.api-keys.table.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="key in activeUserKeys" :key="key.id">
|
||||
<td
|
||||
class="ds-table-col"
|
||||
:title="
|
||||
$t('settings.api-keys.list.created-at') +
|
||||
': ' +
|
||||
$options.filters.dateTime(key.createdAt)
|
||||
"
|
||||
>
|
||||
{{ key.name }}
|
||||
</td>
|
||||
<td class="ds-table-col">
|
||||
<code>{{ key.keyPrefix }}...</code>
|
||||
</td>
|
||||
<td class="ds-table-col">
|
||||
<date-time v-if="key.lastUsedAt" :date-time="key.lastUsedAt" />
|
||||
<template v-else>{{ $t('admin.api-keys.never') }}</template>
|
||||
</td>
|
||||
<td class="ds-table-col">
|
||||
<os-button
|
||||
variant="danger"
|
||||
appearance="outline"
|
||||
circle
|
||||
size="sm"
|
||||
:aria-label="$t('admin.api-keys.revoke-key')"
|
||||
@click="confirmRevokeKey(key, entry)"
|
||||
>
|
||||
<template #icon><os-icon :icon="icons.trash" /></template>
|
||||
</os-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Revoked keys -->
|
||||
<div v-if="revokedUserKeys.length">
|
||||
<h4 class="ds-mb-small revoked-heading">
|
||||
{{ $t('admin.api-keys.detail.revoked', { count: revokedUserKeys.length }) }}
|
||||
</h4>
|
||||
<table class="ds-table ds-table-condensed revoked-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ds-table-head-col">{{ $t('admin.api-keys.table.name') }}</th>
|
||||
<th class="ds-table-head-col">
|
||||
{{ $t('admin.api-keys.table.prefix') }}
|
||||
</th>
|
||||
<th class="ds-table-head-col">{{ $t('admin.api-keys.revoked-at') }}</th>
|
||||
<th class="ds-table-head-col">
|
||||
{{ $t('admin.api-keys.table.last-activity') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="key in revokedUserKeys" :key="key.id">
|
||||
<td class="ds-table-col">{{ key.name }}</td>
|
||||
<td class="ds-table-col">
|
||||
<code>{{ key.keyPrefix }}...</code>
|
||||
</td>
|
||||
<td class="ds-table-col">
|
||||
<date-time v-if="key.disabledAt" :date-time="key.disabledAt" />
|
||||
<template v-else>–</template>
|
||||
</td>
|
||||
<td class="ds-table-col">
|
||||
<date-time v-if="key.lastUsedAt" :date-time="key.lastUsedAt" />
|
||||
<template v-else>{{ $t('admin.api-keys.never') }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<pagination-buttons :hasNext="hasNext" :hasPrevious="hasPrevious" @next="next" @back="back" />
|
||||
</os-card>
|
||||
<os-card v-else>
|
||||
<div class="ds-placeholder">{{ $t('admin.api-keys.empty') }}</div>
|
||||
</os-card>
|
||||
|
||||
<confirm-modal v-if="showModal" :modalData="modalData" @close="showModal = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OsButton, OsCard, OsIcon, OsSpinner } from '@ocelot-social/ui'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
|
||||
import ConfirmModal from '~/components/Modal/ConfirmModal'
|
||||
import DateTime from '~/components/DateTime'
|
||||
import UserTeaser from '~/components/UserTeaser/UserTeaser'
|
||||
import {
|
||||
apiKeyUsersQuery,
|
||||
apiKeysForUserQuery,
|
||||
adminRevokeApiKeyMutation,
|
||||
adminRevokeUserApiKeysMutation,
|
||||
} from '~/graphql/admin/ApiKeys'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
OsButton,
|
||||
OsCard,
|
||||
OsIcon,
|
||||
OsSpinner,
|
||||
PaginationButtons,
|
||||
ConfirmModal,
|
||||
DateTime,
|
||||
UserTeaser,
|
||||
},
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
},
|
||||
data() {
|
||||
const pageSize = 20
|
||||
return {
|
||||
apiKeyUsers: [],
|
||||
offset: 0,
|
||||
pageSize,
|
||||
first: pageSize,
|
||||
hasNext: false,
|
||||
expandedUserId: null,
|
||||
userKeys: null,
|
||||
detailLoading: false,
|
||||
showModal: false,
|
||||
modalData: null,
|
||||
}
|
||||
},
|
||||
apollo: {
|
||||
apiKeyUsers: {
|
||||
query: apiKeyUsersQuery(),
|
||||
variables() {
|
||||
return {
|
||||
first: this.first + 1,
|
||||
offset: this.offset,
|
||||
}
|
||||
},
|
||||
update({ apiKeyUsers }) {
|
||||
this.hasNext = apiKeyUsers.length > this.pageSize
|
||||
return apiKeyUsers.slice(0, this.pageSize)
|
||||
},
|
||||
fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
hasPrevious() {
|
||||
return this.offset > 0
|
||||
},
|
||||
activeUserKeys() {
|
||||
return (this.userKeys || []).filter((k) => !k.disabled)
|
||||
},
|
||||
revokedUserKeys() {
|
||||
return (this.userKeys || []).filter((k) => k.disabled)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
next() {
|
||||
this.offset += this.pageSize
|
||||
},
|
||||
back() {
|
||||
this.offset = Math.max(0, this.offset - this.pageSize)
|
||||
},
|
||||
async toggleUser(userId) {
|
||||
if (this.expandedUserId === userId) {
|
||||
this.expandedUserId = null
|
||||
this.userKeys = null
|
||||
return
|
||||
}
|
||||
this.expandedUserId = userId
|
||||
this.detailLoading = true
|
||||
this.userKeys = null
|
||||
try {
|
||||
const result = await this.$apollo.query({
|
||||
query: apiKeysForUserQuery(),
|
||||
variables: { userId },
|
||||
fetchPolicy: 'network-only',
|
||||
})
|
||||
if (this.expandedUserId !== userId) return
|
||||
this.userKeys = result.data.apiKeysForUser
|
||||
} catch (error) {
|
||||
if (this.expandedUserId !== userId) return
|
||||
this.$toast.error(error.message)
|
||||
} finally {
|
||||
if (this.expandedUserId === userId) {
|
||||
this.detailLoading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmRevokeKey(key, entry) {
|
||||
this.modalData = {
|
||||
titleIdent: 'admin.api-keys.revoke.title',
|
||||
messageIdent: 'admin.api-keys.revoke.message',
|
||||
messageParams: { name: key.name, user: entry.user.name },
|
||||
buttons: {
|
||||
confirm: {
|
||||
danger: true,
|
||||
icon: this.icons.trash,
|
||||
textIdent: 'admin.api-keys.revoke.confirm',
|
||||
callback: () => this.revokeKey(key.id, entry.user.id),
|
||||
},
|
||||
cancel: {
|
||||
icon: this.icons.close,
|
||||
textIdent: 'actions.cancel',
|
||||
callback: () => {},
|
||||
},
|
||||
},
|
||||
}
|
||||
this.showModal = true
|
||||
},
|
||||
confirmRevokeAll(entry) {
|
||||
this.modalData = {
|
||||
titleIdent: 'admin.api-keys.revoke-all-title',
|
||||
messageIdent: 'admin.api-keys.revoke-all-message',
|
||||
messageParams: { user: entry.user.name },
|
||||
buttons: {
|
||||
confirm: {
|
||||
danger: true,
|
||||
icon: this.icons.trash,
|
||||
textIdent: 'admin.api-keys.revoke-all-confirm',
|
||||
callback: () => this.revokeAllKeys(entry.user.id, entry.user.name),
|
||||
},
|
||||
cancel: {
|
||||
icon: this.icons.close,
|
||||
textIdent: 'actions.cancel',
|
||||
callback: () => {},
|
||||
},
|
||||
},
|
||||
}
|
||||
this.showModal = true
|
||||
},
|
||||
async revokeKey(keyId, userId) {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: adminRevokeApiKeyMutation(),
|
||||
variables: { id: keyId },
|
||||
})
|
||||
this.$apollo.queries.apiKeyUsers.refetch()
|
||||
this.expandedUserId = null
|
||||
this.userKeys = null
|
||||
this.$toast.success(this.$t('admin.api-keys.revoke.success'))
|
||||
} catch (error) {
|
||||
this.$toast.error(error.message)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
async revokeAllKeys(userId, userName) {
|
||||
try {
|
||||
const result = await this.$apollo.mutate({
|
||||
mutation: adminRevokeUserApiKeysMutation(),
|
||||
variables: { userId },
|
||||
})
|
||||
const count = result.data.adminRevokeUserApiKeys
|
||||
this.$apollo.queries.apiKeyUsers.refetch()
|
||||
this.expandedUserId = null
|
||||
this.userKeys = null
|
||||
this.$toast.success(this.$t('admin.api-keys.revoke-all-success', { count, user: userName }))
|
||||
} catch (error) {
|
||||
this.$toast.error(error.message)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.table-no-clip {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: $space-xx-small;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.detail-cell {
|
||||
background-color: $color-neutral-90;
|
||||
padding: $space-small;
|
||||
}
|
||||
|
||||
.revoked-table {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.revoked-heading {
|
||||
color: $text-color-soft;
|
||||
}
|
||||
</style>
|
||||
@ -665,8 +665,8 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.profile-page-avatar.profile-avatar {
|
||||
<style scoped lang="scss">
|
||||
::v-deep .profile-page-avatar.profile-avatar {
|
||||
margin: auto;
|
||||
margin-top: -60px;
|
||||
}
|
||||
@ -675,12 +675,10 @@ export default {
|
||||
padding-top: $space-large;
|
||||
}
|
||||
}
|
||||
.group-profile {
|
||||
.group-layout__sidebar .group-profile-content-menu {
|
||||
position: absolute;
|
||||
top: $space-x-small;
|
||||
right: $space-x-small;
|
||||
}
|
||||
.group-layout__sidebar ::v-deep .group-profile-content-menu {
|
||||
position: absolute;
|
||||
top: $space-x-small;
|
||||
right: $space-x-small;
|
||||
}
|
||||
.group-layout__sidebar,
|
||||
.group-layout__main {
|
||||
@ -712,7 +710,7 @@ export default {
|
||||
flex: 3 0 0;
|
||||
}
|
||||
}
|
||||
.profile-post-add-button {
|
||||
::v-deep .profile-post-add-button {
|
||||
box-shadow: $box-shadow-x-large;
|
||||
}
|
||||
.action-buttons {
|
||||
@ -728,7 +726,7 @@ export default {
|
||||
.chip {
|
||||
margin-bottom: $space-x-small;
|
||||
}
|
||||
.group-description.os-card {
|
||||
::v-deep .group-description.os-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
@ -619,8 +619,8 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.profile-page-avatar.profile-avatar {
|
||||
<style scoped lang="scss">
|
||||
::v-deep .profile-page-avatar.profile-avatar {
|
||||
margin: auto;
|
||||
margin-top: -60px;
|
||||
}
|
||||
@ -635,12 +635,10 @@ export default {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
.page-name-profile-id-slug {
|
||||
.profile-layout__sidebar .content-menu {
|
||||
position: absolute;
|
||||
top: $space-x-small;
|
||||
right: $space-x-small;
|
||||
}
|
||||
.profile-layout__sidebar ::v-deep .content-menu {
|
||||
position: absolute;
|
||||
top: $space-x-small;
|
||||
right: $space-x-small;
|
||||
}
|
||||
.profile-layout__sidebar,
|
||||
.profile-layout__main {
|
||||
@ -677,7 +675,7 @@ export default {
|
||||
justify-content: center;
|
||||
margin: $space-small 0;
|
||||
}
|
||||
.profile-post-add-button {
|
||||
::v-deep .profile-post-add-button {
|
||||
box-shadow: $box-shadow-x-large !important;
|
||||
}
|
||||
.action-buttons {
|
||||
|
||||
@ -61,6 +61,9 @@ export default {
|
||||
...(this.$env.INVITE_REGISTRATION === true
|
||||
? [{ name: this.$t('settings.invites.name'), path: `/settings/invites` }]
|
||||
: []),
|
||||
...(this.$env.API_KEYS_ENABLED
|
||||
? [{ name: this.$t('settings.api-keys.name'), path: `/settings/api-keys` }]
|
||||
: []),
|
||||
{
|
||||
name: this.$t('settings.muted-users.name'),
|
||||
path: `/settings/muted-users`,
|
||||
|
||||
428
webapp/pages/settings/api-keys.spec.js
Normal file
428
webapp/pages/settings/api-keys.spec.js
Normal file
@ -0,0 +1,428 @@
|
||||
import Vue from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import flushPromises from 'flush-promises'
|
||||
import ApiKeys from './api-keys.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
// Override dateTime filter to avoid locale dependency
|
||||
Vue.filter('dateTime', (value) => value || '')
|
||||
|
||||
describe('settings/api-keys.vue', () => {
|
||||
let wrapper, mocks
|
||||
const originalClipboard = navigator.clipboard
|
||||
|
||||
const mutateMock = jest.fn()
|
||||
const refetchMock = jest.fn()
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) wrapper.destroy()
|
||||
Object.assign(navigator, { clipboard: originalClipboard })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mutateMock.mockReset()
|
||||
refetchMock.mockReset()
|
||||
|
||||
mocks = {
|
||||
$t: jest.fn((key) => key),
|
||||
$env: { API_KEYS_MAX_PER_USER: 5 },
|
||||
$toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
$apollo: {
|
||||
mutate: mutateMock,
|
||||
queries: {
|
||||
myApiKeys: {
|
||||
refetch: refetchMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const activeKey = (overrides = {}) => ({
|
||||
id: 'k1',
|
||||
name: 'CI Bot',
|
||||
keyPrefix: 'oak_abc12345',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
lastUsedAt: '2026-04-02T10:00:00Z',
|
||||
expiresAt: null,
|
||||
disabled: false,
|
||||
disabledAt: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const revokedKey = (overrides = {}) => ({
|
||||
id: 'k-revoked',
|
||||
name: 'Old Key',
|
||||
keyPrefix: 'oak_old12345',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
lastUsedAt: '2025-05-01T00:00:00Z',
|
||||
expiresAt: null,
|
||||
disabled: true,
|
||||
disabledAt: '2025-06-01T00:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const Wrapper = (data = {}) => {
|
||||
return mount(ApiKeys, {
|
||||
mocks,
|
||||
localVue,
|
||||
stubs: {
|
||||
'nuxt-link': true,
|
||||
'confirm-modal': true,
|
||||
'date-time': { template: '<span>{{ dateTime }}</span>', props: ['dateTime'] },
|
||||
},
|
||||
data: () => ({
|
||||
myApiKeys: [],
|
||||
...data,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
describe('renders', () => {
|
||||
it('shows create form', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.find('#api-key-name').exists()).toBe(true)
|
||||
expect(wrapper.find('#api-key-expiry').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows empty state when no keys exist', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.text()).toContain('settings.api-keys.list.empty')
|
||||
})
|
||||
|
||||
it('shows active key list', () => {
|
||||
wrapper = Wrapper({ myApiKeys: [activeKey()] })
|
||||
expect(wrapper.text()).toContain('CI Bot')
|
||||
expect(wrapper.text()).toContain('oak_abc12345')
|
||||
})
|
||||
|
||||
it('shows key counter', () => {
|
||||
wrapper = Wrapper({ myApiKeys: [activeKey(), activeKey({ id: 'k2', name: 'Key 2' })] })
|
||||
expect(wrapper.text()).toContain('(2/5)')
|
||||
})
|
||||
|
||||
it('shows "never" for keys without lastUsedAt', () => {
|
||||
wrapper = Wrapper({ myApiKeys: [activeKey({ lastUsedAt: null })] })
|
||||
expect(wrapper.text()).toContain('settings.api-keys.list.never')
|
||||
})
|
||||
|
||||
it('shows "never-expires" for keys without expiresAt', () => {
|
||||
wrapper = Wrapper({ myApiKeys: [activeKey()] })
|
||||
expect(wrapper.text()).toContain('settings.api-keys.list.never-expires')
|
||||
})
|
||||
|
||||
it('shows expired label for expired keys', () => {
|
||||
wrapper = Wrapper({
|
||||
myApiKeys: [activeKey({ expiresAt: '2020-01-01T00:00:00Z' })],
|
||||
})
|
||||
expect(wrapper.text()).toContain('settings.api-keys.list.expired')
|
||||
})
|
||||
})
|
||||
|
||||
describe('revoked keys section', () => {
|
||||
it('is hidden when no revoked keys', () => {
|
||||
wrapper = Wrapper({ myApiKeys: [activeKey()] })
|
||||
expect(wrapper.find('.revoked-toggle').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows toggle when revoked keys exist', () => {
|
||||
wrapper = Wrapper({ myApiKeys: [revokedKey()] })
|
||||
expect(wrapper.find('.revoked-toggle').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('settings.api-keys.revoked-list.title')
|
||||
})
|
||||
|
||||
it('is collapsed by default', () => {
|
||||
wrapper = Wrapper({ myApiKeys: [revokedKey()] })
|
||||
expect(wrapper.find('#revoked-keys-list').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('expands on toggle click', async () => {
|
||||
wrapper = Wrapper({ myApiKeys: [revokedKey()] })
|
||||
await wrapper.find('.revoked-toggle').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('#revoked-keys-list').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Old Key')
|
||||
})
|
||||
|
||||
it('shows revoked status label', async () => {
|
||||
wrapper = Wrapper({ myApiKeys: [revokedKey()] })
|
||||
await wrapper.find('.revoked-toggle').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('settings.api-keys.list.revoked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('activeKeys filters out disabled keys', () => {
|
||||
wrapper = Wrapper({ myApiKeys: [activeKey(), revokedKey()] })
|
||||
expect(wrapper.vm.activeKeys).toHaveLength(1)
|
||||
expect(wrapper.vm.activeKeys[0].id).toBe('k1')
|
||||
})
|
||||
|
||||
it('revokedKeys filters to disabled keys only', () => {
|
||||
wrapper = Wrapper({ myApiKeys: [activeKey(), revokedKey()] })
|
||||
expect(wrapper.vm.revokedKeys).toHaveLength(1)
|
||||
expect(wrapper.vm.revokedKeys[0].id).toBe('k-revoked')
|
||||
})
|
||||
|
||||
it('maxKeys reads from $env', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.vm.maxKeys).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('create key', () => {
|
||||
beforeEach(() => {
|
||||
mutateMock.mockResolvedValue({
|
||||
data: {
|
||||
createApiKey: {
|
||||
apiKey: activeKey({ id: 'new-key', name: 'My Key' }),
|
||||
secret: 'oak_fullsecretkey123',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('submit button is disabled when name is empty', () => {
|
||||
wrapper = Wrapper()
|
||||
const submitBtn = wrapper.find('button[type="submit"]')
|
||||
expect(submitBtn.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('submit button is disabled when limit reached', async () => {
|
||||
const keys = Array.from({ length: 5 }, (_, i) => activeKey({ id: `k${i}`, name: `Key ${i}` }))
|
||||
wrapper = Wrapper({ myApiKeys: keys })
|
||||
await wrapper.setData({ name: 'New Key' })
|
||||
const submitBtn = wrapper.find('button[type="submit"]')
|
||||
expect(submitBtn.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows limit warning when max keys reached', () => {
|
||||
const keys = Array.from({ length: 5 }, (_, i) => activeKey({ id: `k${i}`, name: `Key ${i}` }))
|
||||
wrapper = Wrapper({ myApiKeys: keys })
|
||||
expect(wrapper.text()).toContain('settings.api-keys.create.limit-reached')
|
||||
})
|
||||
|
||||
it('does not show limit warning when under limit', () => {
|
||||
wrapper = Wrapper({ myApiKeys: [activeKey()] })
|
||||
expect(wrapper.text()).not.toContain('settings.api-keys.create.limit-reached')
|
||||
})
|
||||
|
||||
it('does not count revoked keys toward the limit', async () => {
|
||||
const keys = [
|
||||
...Array.from({ length: 4 }, (_, i) => activeKey({ id: `k${i}`, name: `Key ${i}` })),
|
||||
revokedKey(),
|
||||
]
|
||||
wrapper = Wrapper({ myApiKeys: keys })
|
||||
await wrapper.setData({ name: 'New Key' })
|
||||
const submitBtn = wrapper.find('button[type="submit"]')
|
||||
expect(submitBtn.attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.text()).not.toContain('settings.api-keys.create.limit-reached')
|
||||
})
|
||||
|
||||
it('calls createApiKey mutation on submit', async () => {
|
||||
wrapper = Wrapper()
|
||||
await wrapper.setData({ name: 'My Key' })
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(mutateMock).toHaveBeenCalledTimes(1)
|
||||
expect(mutateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: { name: 'My Key' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('sends expiresInDays as number when selected', async () => {
|
||||
wrapper = Wrapper()
|
||||
await wrapper.setData({ name: 'Expiring', expiresInDays: 30 })
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(mutateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: { name: 'Expiring', expiresInDays: 30 },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not send expiresInDays when null', async () => {
|
||||
wrapper = Wrapper()
|
||||
await wrapper.setData({ name: 'No Expiry', expiresInDays: null })
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(mutateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: { name: 'No Expiry' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows secret banner after creation', async () => {
|
||||
wrapper = Wrapper()
|
||||
await wrapper.setData({ name: 'My Key' })
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.newSecret).toBe('oak_fullsecretkey123')
|
||||
await flushPromises()
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('settings.api-keys.secret.title')
|
||||
})
|
||||
|
||||
it('refetches key list after creation', async () => {
|
||||
wrapper = Wrapper()
|
||||
await wrapper.setData({ name: 'My Key' })
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(refetchMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows success toast', async () => {
|
||||
wrapper = Wrapper()
|
||||
await wrapper.setData({ name: 'My Key' })
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(mocks.$toast.success).toHaveBeenCalledWith('settings.api-keys.create.success')
|
||||
})
|
||||
|
||||
it('resets form after creation', async () => {
|
||||
wrapper = Wrapper()
|
||||
await wrapper.setData({ name: 'My Key', expiresInDays: 90 })
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.name).toBe('')
|
||||
expect(wrapper.vm.expiresInDays).toBeNull()
|
||||
})
|
||||
|
||||
it('shows error toast on failure', async () => {
|
||||
mutateMock.mockRejectedValue(new Error('Maximum of 5 active API keys reached'))
|
||||
wrapper = Wrapper()
|
||||
await wrapper.setData({ name: 'Too Many' })
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(mocks.$toast.error).toHaveBeenCalledWith('Maximum of 5 active API keys reached')
|
||||
})
|
||||
|
||||
it('does not submit when name is empty', async () => {
|
||||
wrapper = Wrapper()
|
||||
await wrapper.setData({ name: ' ' })
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(mutateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets creating to true during mutation', async () => {
|
||||
let resolveMutate
|
||||
mutateMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveMutate = resolve
|
||||
}),
|
||||
)
|
||||
wrapper = Wrapper()
|
||||
await wrapper.setData({ name: 'Loading Test' })
|
||||
const submitPromise = wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.vm.creating).toBe(true)
|
||||
resolveMutate({
|
||||
data: {
|
||||
createApiKey: {
|
||||
apiKey: activeKey({ id: 'new', name: 'Loading Test' }),
|
||||
secret: 'oak_secret',
|
||||
},
|
||||
},
|
||||
})
|
||||
await submitPromise
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.creating).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('copy secret', () => {
|
||||
it('copies secret to clipboard on success', async () => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: jest.fn().mockResolvedValue(undefined) },
|
||||
})
|
||||
wrapper = Wrapper()
|
||||
await wrapper.setData({ newSecret: 'oak_mysecret123' })
|
||||
await wrapper.vm.copySecret()
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('oak_mysecret123')
|
||||
expect(mocks.$toast.success).toHaveBeenCalledWith('settings.api-keys.secret.copied')
|
||||
})
|
||||
|
||||
it('shows error toast when clipboard fails', async () => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: jest.fn().mockRejectedValue(new Error('denied')) },
|
||||
})
|
||||
wrapper = Wrapper()
|
||||
await wrapper.setData({ newSecret: 'oak_mysecret123' })
|
||||
await wrapper.vm.copySecret()
|
||||
expect(mocks.$toast.error).toHaveBeenCalledWith('settings.api-keys.secret.copy-failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('revoke key', () => {
|
||||
it('opens confirm modal', async () => {
|
||||
wrapper = Wrapper({ myApiKeys: [activeKey()] })
|
||||
await wrapper.find('button[aria-label="settings.api-keys.list.revoke"]').trigger('click')
|
||||
expect(wrapper.vm.showRevokeModal).toBe(true)
|
||||
expect(wrapper.vm.revokeModalData.messageParams.name).toBe('CI Bot')
|
||||
})
|
||||
|
||||
it('calls revokeApiKey mutation and refetches', async () => {
|
||||
mutateMock.mockResolvedValue({ data: { revokeApiKey: true } })
|
||||
wrapper = Wrapper({ myApiKeys: [activeKey()] })
|
||||
await wrapper.vm.revokeKey({ id: 'k1', name: 'CI Bot' })
|
||||
await flushPromises()
|
||||
expect(mutateMock).toHaveBeenCalledWith(expect.objectContaining({ variables: { id: 'k1' } }))
|
||||
expect(refetchMock).toHaveBeenCalled()
|
||||
expect(mocks.$toast.success).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets revokingKeyId during mutation', async () => {
|
||||
let resolveMutate
|
||||
mutateMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveMutate = resolve
|
||||
}),
|
||||
)
|
||||
wrapper = Wrapper({ myApiKeys: [activeKey()] })
|
||||
const revokePromise = wrapper.vm.revokeKey({ id: 'k1', name: 'CI Bot' })
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.vm.revokingKeyId).toBe('k1')
|
||||
resolveMutate({ data: { revokeApiKey: true } })
|
||||
await revokePromise
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.revokingKeyId).toBeNull()
|
||||
})
|
||||
|
||||
it('shows error toast on revoke failure', async () => {
|
||||
mutateMock.mockRejectedValue(new Error('Network error'))
|
||||
wrapper = Wrapper({ myApiKeys: [activeKey()] })
|
||||
await expect(wrapper.vm.revokeKey({ id: 'k1', name: 'CI Bot' })).rejects.toThrow(
|
||||
'Network error',
|
||||
)
|
||||
await flushPromises()
|
||||
expect(mocks.$toast.error).toHaveBeenCalledWith('Network error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isExpired', () => {
|
||||
it('returns true for past expiresAt', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.vm.isExpired({ expiresAt: '2020-01-01T00:00:00Z' })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for future expiresAt', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.vm.isExpired({ expiresAt: '2099-01-01T00:00:00Z' })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for null expiresAt', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.vm.isExpired({ expiresAt: null })).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
470
webapp/pages/settings/api-keys.vue
Normal file
470
webapp/pages/settings/api-keys.vue
Normal file
@ -0,0 +1,470 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Create new key -->
|
||||
<os-card class="ds-mb-large">
|
||||
<h2 class="title">{{ $t('settings.api-keys.create.title') }}</h2>
|
||||
<form @submit.prevent="createKey">
|
||||
<div class="ds-mb-small">
|
||||
<ocelot-input
|
||||
id="api-key-name"
|
||||
model="name"
|
||||
:value="name"
|
||||
:label="$t('settings.api-keys.create.name-label')"
|
||||
@input="name = $event"
|
||||
/>
|
||||
</div>
|
||||
<div class="ds-mb-small">
|
||||
<label class="ds-label" for="api-key-expiry">
|
||||
{{ $t('settings.api-keys.create.expiry-label') }}
|
||||
</label>
|
||||
<select id="api-key-expiry" v-model="expiresInDays" class="settings-select">
|
||||
<option :value="null">{{ $t('settings.api-keys.create.expiry-never') }}</option>
|
||||
<option :value="30">
|
||||
{{ $t('settings.api-keys.create.expiry-days', { days: 30 }) }}
|
||||
</option>
|
||||
<option :value="90">
|
||||
{{ $t('settings.api-keys.create.expiry-days', { days: 90 }) }}
|
||||
</option>
|
||||
<option :value="180">
|
||||
{{ $t('settings.api-keys.create.expiry-days', { days: 180 }) }}
|
||||
</option>
|
||||
<option :value="365">
|
||||
{{ $t('settings.api-keys.create.expiry-days', { days: 365 }) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p v-if="activeKeys.length >= maxKeys" class="ds-text ds-text-small limit-warning">
|
||||
{{ $t('settings.api-keys.create.limit-reached', { max: maxKeys }) }}
|
||||
</p>
|
||||
<os-button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
data-test="create-api-key-submit"
|
||||
:disabled="!name.trim() || activeKeys.length >= maxKeys"
|
||||
:loading="creating"
|
||||
>
|
||||
{{ $t('settings.api-keys.create.submit') }}
|
||||
</os-button>
|
||||
</form>
|
||||
</os-card>
|
||||
|
||||
<!-- Secret banner (shown once after creation) -->
|
||||
<os-card v-if="newSecret" class="ds-mb-large secret-banner">
|
||||
<h3 class="title">{{ $t('settings.api-keys.secret.title') }}</h3>
|
||||
<div class="secret-display">
|
||||
<code class="secret-code">{{ newSecret }}</code>
|
||||
<os-button
|
||||
v-if="canCopy"
|
||||
variant="primary"
|
||||
appearance="outline"
|
||||
size="sm"
|
||||
@click="copySecret"
|
||||
>
|
||||
<template #icon><os-icon :icon="icons.copy" /></template>
|
||||
{{ $t('settings.api-keys.secret.copy') }}
|
||||
</os-button>
|
||||
</div>
|
||||
<p class="ds-text ds-text-small secret-warning">
|
||||
{{ $t('settings.api-keys.secret.warning') }}
|
||||
</p>
|
||||
</os-card>
|
||||
|
||||
<!-- Active keys -->
|
||||
<os-card class="ds-mb-large">
|
||||
<h2 class="title">
|
||||
{{ $t('settings.api-keys.list.title') }}
|
||||
<span class="key-counter">({{ activeKeys.length }}/{{ maxKeys }})</span>
|
||||
</h2>
|
||||
<div v-if="activeKeys.length" class="ds-table-wrap">
|
||||
<table class="ds-table ds-table-condensed ds-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="ds-table-head-col">
|
||||
{{ $t('settings.api-keys.list.name') }}
|
||||
</th>
|
||||
<th scope="col" class="ds-table-head-col">
|
||||
{{ $t('settings.api-keys.list.prefix') }}
|
||||
</th>
|
||||
<th scope="col" class="ds-table-head-col">
|
||||
{{ $t('settings.api-keys.list.expires') }}
|
||||
</th>
|
||||
<th scope="col" class="ds-table-head-col">
|
||||
{{ $t('settings.api-keys.list.last-used') }}
|
||||
</th>
|
||||
<th scope="col" class="ds-table-head-col">
|
||||
{{ $t('settings.api-keys.list.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="key in activeKeys" :key="key.id">
|
||||
<td
|
||||
class="ds-table-col"
|
||||
:title="
|
||||
$t('settings.api-keys.list.created-at') +
|
||||
': ' +
|
||||
$options.filters.dateTime(key.createdAt)
|
||||
"
|
||||
>
|
||||
{{ key.name }}
|
||||
</td>
|
||||
<td
|
||||
class="ds-table-col"
|
||||
:title="
|
||||
$t('settings.api-keys.list.created-at') +
|
||||
': ' +
|
||||
$options.filters.dateTime(key.createdAt)
|
||||
"
|
||||
>
|
||||
<code>{{ key.keyPrefix }}...</code>
|
||||
</td>
|
||||
<td class="ds-table-col">
|
||||
<template v-if="isExpired(key)">
|
||||
<span class="status-label">{{ $t('settings.api-keys.list.expired') }}</span>
|
||||
</template>
|
||||
<template v-else-if="key.expiresAt">
|
||||
<date-time :date-time="key.expiresAt" />
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('settings.api-keys.list.never-expires') }}
|
||||
</template>
|
||||
</td>
|
||||
<td class="ds-table-col">
|
||||
<date-time v-if="key.lastUsedAt" :date-time="key.lastUsedAt" />
|
||||
<template v-else>{{ $t('settings.api-keys.list.never') }}</template>
|
||||
</td>
|
||||
<td class="ds-table-col">
|
||||
<os-button
|
||||
variant="danger"
|
||||
appearance="outline"
|
||||
circle
|
||||
size="sm"
|
||||
:loading="revokingKeyId === key.id"
|
||||
:aria-label="$t('settings.api-keys.list.revoke')"
|
||||
data-test="revoke-api-key"
|
||||
@click="confirmRevoke(key)"
|
||||
>
|
||||
<template #icon><os-icon :icon="icons.trash" /></template>
|
||||
</os-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="ds-placeholder">
|
||||
{{ $t('settings.api-keys.list.empty') }}
|
||||
</div>
|
||||
|
||||
<!-- Revoked keys (collapsible) -->
|
||||
<div v-if="revokedKeys.length" class="revoked-section">
|
||||
<button
|
||||
class="revoked-toggle"
|
||||
:aria-expanded="String(showRevoked)"
|
||||
aria-controls="revoked-keys-list"
|
||||
@click="showRevoked = !showRevoked"
|
||||
>
|
||||
<span>
|
||||
{{ $t('settings.api-keys.revoked-list.title', { count: revokedKeys.length }) }}
|
||||
</span>
|
||||
<span class="revoked-chevron" :class="{ open: showRevoked }">▼</span>
|
||||
</button>
|
||||
<div v-if="showRevoked" id="revoked-keys-list" class="ds-table-wrap">
|
||||
<table class="ds-table ds-table-condensed ds-table-bordered revoked-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="ds-table-head-col">
|
||||
{{ $t('settings.api-keys.list.name') }}
|
||||
</th>
|
||||
<th scope="col" class="ds-table-head-col">
|
||||
{{ $t('settings.api-keys.list.prefix') }}
|
||||
</th>
|
||||
<th scope="col" class="ds-table-head-col">
|
||||
{{ $t('settings.api-keys.revoked-list.revoked-at') }}
|
||||
</th>
|
||||
<th scope="col" class="ds-table-head-col">
|
||||
{{ $t('settings.api-keys.list.last-used') }}
|
||||
</th>
|
||||
<th scope="col" class="ds-table-head-col">
|
||||
{{ $t('settings.api-keys.list.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="key in revokedKeys" :key="key.id">
|
||||
<td
|
||||
class="ds-table-col"
|
||||
:title="
|
||||
$t('settings.api-keys.list.created-at') +
|
||||
': ' +
|
||||
$options.filters.dateTime(key.createdAt)
|
||||
"
|
||||
>
|
||||
{{ key.name }}
|
||||
</td>
|
||||
<td
|
||||
class="ds-table-col"
|
||||
:title="
|
||||
$t('settings.api-keys.list.created-at') +
|
||||
': ' +
|
||||
$options.filters.dateTime(key.createdAt)
|
||||
"
|
||||
>
|
||||
<code>{{ key.keyPrefix }}...</code>
|
||||
</td>
|
||||
<td class="ds-table-col">
|
||||
<date-time v-if="key.disabledAt" :date-time="key.disabledAt" />
|
||||
<template v-else>–</template>
|
||||
</td>
|
||||
<td class="ds-table-col">
|
||||
<date-time v-if="key.lastUsedAt" :date-time="key.lastUsedAt" />
|
||||
<template v-else>{{ $t('settings.api-keys.list.never') }}</template>
|
||||
</td>
|
||||
<td class="ds-table-col">
|
||||
<span class="status-label">{{ $t('settings.api-keys.list.revoked') }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</os-card>
|
||||
|
||||
<!-- Confirm revoke modal -->
|
||||
<confirm-modal
|
||||
v-if="showRevokeModal"
|
||||
:modalData="revokeModalData"
|
||||
@close="showRevokeModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OsButton, OsCard, OsIcon } from '@ocelot-social/ui'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import OcelotInput from '~/components/OcelotInput/OcelotInput'
|
||||
import ConfirmModal from '~/components/Modal/ConfirmModal'
|
||||
import DateTime from '~/components/DateTime'
|
||||
import {
|
||||
myApiKeysQuery,
|
||||
createApiKeyMutation,
|
||||
revokeApiKeyMutation,
|
||||
} from '~/graphql/settings/ApiKeys'
|
||||
import scrollToContent from './scroll-to-content.js'
|
||||
|
||||
export default {
|
||||
mixins: [scrollToContent],
|
||||
components: {
|
||||
OsButton,
|
||||
OsCard,
|
||||
OsIcon,
|
||||
OcelotInput,
|
||||
ConfirmModal,
|
||||
DateTime,
|
||||
},
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
this.canCopy = typeof navigator !== 'undefined' && !!navigator.clipboard
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
myApiKeys: [],
|
||||
name: '',
|
||||
expiresInDays: null,
|
||||
creating: false,
|
||||
newSecret: null,
|
||||
canCopy: false,
|
||||
revokingKeyId: null,
|
||||
showRevokeModal: false,
|
||||
revokeModalData: null,
|
||||
showRevoked: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
maxKeys() {
|
||||
return this.$env.API_KEYS_MAX_PER_USER || 5
|
||||
},
|
||||
activeKeys() {
|
||||
return (this.myApiKeys || []).filter((k) => !k.disabled)
|
||||
},
|
||||
revokedKeys() {
|
||||
return (this.myApiKeys || []).filter((k) => k.disabled)
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
myApiKeys: { query: myApiKeysQuery(), fetchPolicy: 'cache-and-network' },
|
||||
},
|
||||
methods: {
|
||||
async createKey() {
|
||||
if (!this.name.trim()) return
|
||||
this.creating = true
|
||||
try {
|
||||
const variables = { name: this.name.trim() }
|
||||
if (this.expiresInDays) {
|
||||
variables.expiresInDays = Number(this.expiresInDays)
|
||||
}
|
||||
const result = await this.$apollo.mutate({
|
||||
mutation: createApiKeyMutation(),
|
||||
variables,
|
||||
})
|
||||
this.newSecret = result.data.createApiKey.secret
|
||||
this.name = ''
|
||||
this.expiresInDays = null
|
||||
this.$apollo.queries.myApiKeys.refetch()
|
||||
this.$toast.success(this.$t('settings.api-keys.create.success'))
|
||||
} catch (error) {
|
||||
this.$toast.error(error.message)
|
||||
} finally {
|
||||
this.creating = false
|
||||
}
|
||||
},
|
||||
async copySecret() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.newSecret)
|
||||
this.$toast.success(this.$t('settings.api-keys.secret.copied'))
|
||||
} catch {
|
||||
this.$toast.error(this.$t('settings.api-keys.secret.copy-failed'))
|
||||
}
|
||||
},
|
||||
confirmRevoke(key) {
|
||||
this.revokeModalData = {
|
||||
titleIdent: 'settings.api-keys.revoke.title',
|
||||
messageIdent: 'settings.api-keys.revoke.message',
|
||||
messageParams: { name: key.name },
|
||||
buttons: {
|
||||
confirm: {
|
||||
danger: true,
|
||||
icon: this.icons.trash,
|
||||
textIdent: 'settings.api-keys.revoke.confirm',
|
||||
callback: () => this.revokeKey(key),
|
||||
},
|
||||
cancel: {
|
||||
icon: this.icons.close,
|
||||
textIdent: 'actions.cancel',
|
||||
callback: () => {},
|
||||
},
|
||||
},
|
||||
}
|
||||
this.showRevokeModal = true
|
||||
},
|
||||
async revokeKey(key) {
|
||||
this.revokingKeyId = key.id
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: revokeApiKeyMutation(),
|
||||
variables: { id: key.id },
|
||||
})
|
||||
this.$apollo.queries.myApiKeys.refetch()
|
||||
this.$toast.success(this.$t('settings.api-keys.revoke.success', { name: key.name }))
|
||||
} catch (error) {
|
||||
this.$toast.error(error.message)
|
||||
throw error
|
||||
} finally {
|
||||
this.revokingKeyId = null
|
||||
}
|
||||
},
|
||||
isExpired(key) {
|
||||
return key.expiresAt && new Date(key.expiresAt) < new Date()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.secret-banner {
|
||||
background-color: $color-warning-inverse;
|
||||
border: 1px solid $color-warning;
|
||||
}
|
||||
|
||||
.secret-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-x-small;
|
||||
margin: $space-x-small 0;
|
||||
}
|
||||
|
||||
.secret-code {
|
||||
flex: 1;
|
||||
padding: $space-x-small;
|
||||
background: $background-color-base;
|
||||
border: 1px solid $color-neutral-80;
|
||||
border-radius: $border-radius-base;
|
||||
word-break: break-all;
|
||||
font-size: $font-size-small;
|
||||
}
|
||||
|
||||
.secret-warning {
|
||||
color: $color-warning;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.key-counter {
|
||||
font-weight: normal;
|
||||
font-size: $font-size-base;
|
||||
color: $color-neutral-50;
|
||||
}
|
||||
|
||||
.limit-warning {
|
||||
color: $color-warning;
|
||||
margin-bottom: $space-x-small;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: $font-size-small;
|
||||
color: $text-color-soft;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.revoked-section {
|
||||
margin-top: $space-large;
|
||||
}
|
||||
|
||||
.revoked-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-x-small;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: $text-color-soft;
|
||||
padding: $space-x-small 0;
|
||||
font-size: $font-size-base;
|
||||
|
||||
&:hover {
|
||||
color: $text-color-base;
|
||||
}
|
||||
}
|
||||
|
||||
.revoked-chevron {
|
||||
font-size: $font-size-small;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
tr > th:last-child,
|
||||
tr > td:last-child {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
background-color: $background-color-base;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.revoked-table {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.settings-select {
|
||||
width: 100%;
|
||||
padding: $space-x-small;
|
||||
font-size: $font-size-base;
|
||||
border: 1px solid $color-neutral-80;
|
||||
border-radius: $border-radius-base;
|
||||
background-color: $background-color-base;
|
||||
}
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user