feat(backend): allow to authorize via api key, interfaces to create and manage api keys (#9482)

This commit is contained in:
Ulf Gebhardt 2026-04-03 16:48:11 +02:00 committed by GitHub
parent 0bf724f0c0
commit ceb46263e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 4178 additions and 52 deletions

View File

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

View File

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

View File

@ -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 = {

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
mutation adminRevokeApiKey($id: ID!) {
adminRevokeApiKey(id: $id)
}

View File

@ -0,0 +1,3 @@
mutation adminRevokeUserApiKeys($userId: ID!) {
adminRevokeUserApiKeys(userId: $userId)
}

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

View File

@ -0,0 +1,12 @@
query apiKeysForUser($userId: ID!) {
apiKeysForUser(userId: $userId) {
id
name
keyPrefix
createdAt
lastUsedAt
expiresAt
disabled
disabledAt
}
}

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

View File

@ -0,0 +1,12 @@
query myApiKeys {
myApiKeys {
id
name
keyPrefix
createdAt
lastUsedAt
expiresAt
disabled
disabledAt
}
}

View File

@ -0,0 +1,3 @@
mutation revokeApiKey($id: ID!) {
revokeApiKey(id: $id)
}

View File

@ -0,0 +1,12 @@
mutation updateApiKey($id: ID!, $name: String!) {
updateApiKey(id: $id, name: $name) {
id
name
keyPrefix
createdAt
lastUsedAt
expiresAt
disabled
disabledAt
}
}

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

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

View File

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

View File

@ -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) {

View File

@ -12,6 +12,9 @@ export default makeAugmentedSchema({
config: {
query: {
exclude: [
'ApiKey',
'ApiKeyWithSecret',
'ApiKeyUserSummary',
'Badge',
'Embed',
'EmailNotificationSettings',

View 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!
}

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = {

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "Нажмите на награду, чтобы поместить её в выбранное место.",

View File

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

View File

@ -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": "Натисніть на значок, щоб використати його у вибраному місці.",

View File

@ -14,6 +14,7 @@ describe('admin.vue', () => {
beforeEach(() => {
mocks = {
$t: jest.fn(),
$env: { API_KEYS_ENABLED: false },
}
})

View File

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

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

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

View File

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

View File

@ -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 {

View File

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

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

View 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 }">&#9660;</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>